consolidate shop pages into unified LiveView for editor state persistence
All checks were successful
deploy / deploy (push) Successful in 1m27s
All checks were successful
deploy / deploy (push) Successful in 1m27s
Replace individual shop LiveViews with a single Shop.Page that dispatches to page modules based on live_action. This enables patch navigation between pages, preserving socket state (including editor state) across transitions. Changes: - Add Shop.Page unified LiveView with handle_params dispatch - Extract page logic into Shop.Pages.* modules (Home, Product, Collection, etc.) - Update router to use Shop.Page with live_action for all shop routes - Change navigate= to patch= in shop component links - Add maybe_sync_editing_blocks to reload editor state when page changes - Track editor_page_slug to detect cross-page navigation while editing - Fix picture element height when hover image disabled - Extract ThemeEditor components for shared use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ae0a149ecd
commit
bb5d220079
@ -2534,6 +2534,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On larger screens, hide the overlay so you can navigate while editing */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.editor-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Editor panel ── */
|
/* ── Editor panel ── */
|
||||||
.editor-panel {
|
.editor-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -57,7 +57,8 @@
|
|||||||
background-color: #e5e7eb;
|
background-color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-card-image-wrap img {
|
.product-card-image-wrap img,
|
||||||
|
.product-card-image-wrap picture {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ defmodule BerrypodWeb.ShopComponents do
|
|||||||
- `Cart` — cart drawer, cart items, order summary
|
- `Cart` — cart drawer, cart items, order summary
|
||||||
- `Product` — product cards, gallery, variant selector, hero sections
|
- `Product` — product cards, gallery, variant selector, hero sections
|
||||||
- `Content` — rich text, responsive images, contact form, reviews
|
- `Content` — rich text, responsive images, contact form, reviews
|
||||||
|
- `ThemeEditor` — shared theme editor components for admin and on-site editing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
defmacro __using__(_opts \\ []) do
|
defmacro __using__(_opts \\ []) do
|
||||||
@ -18,6 +19,7 @@ defmodule BerrypodWeb.ShopComponents do
|
|||||||
import BerrypodWeb.ShopComponents.Content
|
import BerrypodWeb.ShopComponents.Content
|
||||||
import BerrypodWeb.ShopComponents.Layout
|
import BerrypodWeb.ShopComponents.Layout
|
||||||
import BerrypodWeb.ShopComponents.Product
|
import BerrypodWeb.ShopComponents.Product
|
||||||
|
import BerrypodWeb.ShopComponents.ThemeEditor
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -171,7 +171,7 @@ defmodule BerrypodWeb.ShopComponents.Base do
|
|||||||
def shop_link_button(assigns) do
|
def shop_link_button(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link
|
<.link
|
||||||
navigate={@href}
|
patch={@href}
|
||||||
class={["themed-button", @class]}
|
class={["themed-button", @class]}
|
||||||
>
|
>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
@ -203,7 +203,7 @@ defmodule BerrypodWeb.ShopComponents.Base do
|
|||||||
def shop_link_outline(assigns) do
|
def shop_link_outline(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link
|
<.link
|
||||||
navigate={@href}
|
patch={@href}
|
||||||
class={["themed-button-outline", @class]}
|
class={["themed-button-outline", @class]}
|
||||||
>
|
>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
|
|||||||
@ -178,7 +178,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
>
|
>
|
||||||
<%= if @mode != :preview do %>
|
<%= if @mode != :preview do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{@item.product_id}"}
|
patch={"/products/#{@item.product_id}"}
|
||||||
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
|
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
|
||||||
data-size={if @size == :compact, do: "compact"}
|
data-size={if @size == :compact, do: "compact"}
|
||||||
style={if @item.image, do: "background-image: url('#{@item.image}');"}
|
style={if @item.image, do: "background-image: url('#{@item.image}');"}
|
||||||
@ -197,7 +197,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
|
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
|
||||||
<%= if @mode != :preview do %>
|
<%= if @mode != :preview do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{@item.product_id}"}
|
patch={"/products/#{@item.product_id}"}
|
||||||
class="cart-item-name-link"
|
class="cart-item-name-link"
|
||||||
>
|
>
|
||||||
{@item.name}
|
{@item.name}
|
||||||
@ -296,7 +296,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
</button>
|
</button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate="/collections/all"
|
patch="/collections/all"
|
||||||
class="cart-continue-link"
|
class="cart-continue-link"
|
||||||
>
|
>
|
||||||
Continue shopping
|
Continue shopping
|
||||||
|
|||||||
@ -266,7 +266,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={@href}
|
patch={@href}
|
||||||
class="mobile-nav-link"
|
class="mobile-nav-link"
|
||||||
aria-current={if @is_current, do: "page", else: nil}
|
aria-current={if @is_current, do: "page", else: nil}
|
||||||
>
|
>
|
||||||
@ -484,7 +484,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
>
|
>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{item.product.slug || item.product.id}"}
|
patch={"/products/#{item.product.slug || item.product.id}"}
|
||||||
class="search-result"
|
class="search-result"
|
||||||
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
|
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
|
||||||
>
|
>
|
||||||
@ -588,7 +588,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={item["href"]}
|
patch={item["href"]}
|
||||||
class="mobile-nav-link"
|
class="mobile-nav-link"
|
||||||
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
|
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
|
||||||
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
|
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
|
||||||
@ -615,7 +615,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/collections/#{category.slug}"}
|
patch={"/collections/#{category.slug}"}
|
||||||
class="mobile-nav-link"
|
class="mobile-nav-link"
|
||||||
phx-click={
|
phx-click={
|
||||||
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
|
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
|
||||||
@ -700,7 +700,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<% else %>
|
<% else %>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate="/collections/all"
|
patch="/collections/all"
|
||||||
class="footer-link"
|
class="footer-link"
|
||||||
>
|
>
|
||||||
All products
|
All products
|
||||||
@ -709,7 +709,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<%= for category <- @categories do %>
|
<%= for category <- @categories do %>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/collections/#{category.slug}"}
|
patch={"/collections/#{category.slug}"}
|
||||||
class="footer-link"
|
class="footer-link"
|
||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
@ -735,7 +735,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
{item["label"]}
|
{item["label"]}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link navigate={item["href"]} class="footer-link">
|
<.link patch={item["href"]} class="footer-link">
|
||||||
{item["label"]}
|
{item["label"]}
|
||||||
</.link>
|
</.link>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -929,7 +929,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link navigate="/" class="shop-logo-link">
|
<.link patch="/" class="shop-logo-link">
|
||||||
<.logo_inner
|
<.logo_inner
|
||||||
theme_settings={@theme_settings}
|
theme_settings={@theme_settings}
|
||||||
site_name={@site_name}
|
site_name={@site_name}
|
||||||
@ -1015,7 +1015,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
{@label}
|
{@label}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link navigate={@href} class="nav-link">
|
<.link patch={@href} class="nav-link">
|
||||||
{@label}
|
{@label}
|
||||||
</.link>
|
</.link>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@ -157,7 +157,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</p>
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/collections/#{Slug.slugify(@product.category)}"}
|
patch={"/collections/#{Slug.slugify(@product.category)}"}
|
||||||
class="product-card-category"
|
class="product-card-category"
|
||||||
>
|
>
|
||||||
{@product.category}
|
{@product.category}
|
||||||
@ -177,7 +177,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"}
|
patch={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"}
|
||||||
class="stretched-link"
|
class="stretched-link"
|
||||||
>
|
>
|
||||||
{@product.title}
|
{@product.title}
|
||||||
@ -205,7 +205,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
defp product_card_image_wrap(assigns) do
|
defp product_card_image_wrap(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @href do %>
|
<%= if @href do %>
|
||||||
<.link navigate={@href} class="product-card-image-wrap" tabindex="-1" aria-hidden="true">
|
<.link patch={@href} class="product-card-image-wrap" tabindex="-1" aria-hidden="true">
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</.link>
|
</.link>
|
||||||
<% else %>
|
<% else %>
|
||||||
@ -571,7 +571,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</button>
|
</button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={@href || "/"}
|
patch={@href || "/"}
|
||||||
class={@cta_class}
|
class={@cta_class}
|
||||||
>
|
>
|
||||||
{@text}
|
{@text}
|
||||||
@ -627,7 +627,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/collections/#{category.slug}"}
|
patch={"/collections/#{category.slug}"}
|
||||||
class="category-card"
|
class="category-card"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -713,7 +713,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</button>
|
</button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={@cta_href}
|
patch={@cta_href}
|
||||||
class="outline-button"
|
class="outline-button"
|
||||||
>
|
>
|
||||||
{@cta_text}
|
{@cta_text}
|
||||||
@ -826,7 +826,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={@link_href || "/"}
|
patch={@link_href || "/"}
|
||||||
class="accent-link"
|
class="accent-link"
|
||||||
>
|
>
|
||||||
{@link_text}
|
{@link_text}
|
||||||
@ -960,7 +960,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link navigate={item.href || "/"}>{item.label}</.link>
|
<.link patch={item.href || "/"}>{item.label}</.link>
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -1749,7 +1749,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
<div class="shop-pagination-buttons">
|
<div class="shop-pagination-buttons">
|
||||||
<.link
|
<.link
|
||||||
:if={@page.page > 1}
|
:if={@page.page > 1}
|
||||||
navigate={pagination_url(@base_path, @page.page - 1, @params)}
|
patch={pagination_url(@base_path, @page.page - 1, @params)}
|
||||||
class="shop-pagination-btn"
|
class="shop-pagination-btn"
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
@ -1762,7 +1762,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
<span class="shop-pagination-ellipsis" aria-hidden="true">…</span>
|
<span class="shop-pagination-ellipsis" aria-hidden="true">…</span>
|
||||||
<% n -> %>
|
<% n -> %>
|
||||||
<.link
|
<.link
|
||||||
navigate={pagination_url(@base_path, n, @params)}
|
patch={pagination_url(@base_path, n, @params)}
|
||||||
aria-label={"Page #{n}"}
|
aria-label={"Page #{n}"}
|
||||||
aria-current={n == @page.page && "page"}
|
aria-current={n == @page.page && "page"}
|
||||||
class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]}
|
class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]}
|
||||||
@ -1774,7 +1774,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
|
|
||||||
<.link
|
<.link
|
||||||
:if={@page.page < @page.total_pages}
|
:if={@page.page < @page.total_pages}
|
||||||
navigate={pagination_url(@base_path, @page.page + 1, @params)}
|
patch={pagination_url(@base_path, @page.page + 1, @params)}
|
||||||
class="shop-pagination-btn"
|
class="shop-pagination-btn"
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
|
|||||||
731
lib/berrypod_web/components/shop_components/theme_editor.ex
Normal file
731
lib/berrypod_web/components/shop_components/theme_editor.ex
Normal file
@ -0,0 +1,731 @@
|
|||||||
|
defmodule BerrypodWeb.ShopComponents.ThemeEditor do
|
||||||
|
@moduledoc """
|
||||||
|
Shared theme editor components used in both:
|
||||||
|
- Admin theme page (`/admin/theme`)
|
||||||
|
- On-site editor panel (page editor Theme tab)
|
||||||
|
|
||||||
|
Components render settings controls that emit standard events:
|
||||||
|
- `update_setting` / `theme_update_setting` (phx-click/phx-change)
|
||||||
|
- `toggle_setting` / `theme_toggle_setting` (phx-click)
|
||||||
|
- `apply_preset` / `theme_apply_preset` (phx-click)
|
||||||
|
- `update_color` / `theme_update_color` (phx-change)
|
||||||
|
|
||||||
|
The event prefix is controlled by `@event_prefix`:
|
||||||
|
- `""` (default) for admin context
|
||||||
|
- `"theme_"` for on-site editor context
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
# ── Quick Settings ─────────────────────────────────────────────────
|
||||||
|
# These are the core settings shown in both compact and full modes.
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the shop name input field.
|
||||||
|
"""
|
||||||
|
attr :site_name, :string, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def shop_name_input(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Shop name</label>
|
||||||
|
<form phx-change={@event_prefix <> "update_setting"} phx-value-field="site_name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="site_name"
|
||||||
|
value={@site_name}
|
||||||
|
placeholder="Your shop name"
|
||||||
|
class="admin-input"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the preset grid for quick theme switching.
|
||||||
|
"""
|
||||||
|
attr :presets, :list, required: true
|
||||||
|
attr :active_preset, :atom, default: nil
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
attr :label, :string, default: "Preset"
|
||||||
|
|
||||||
|
def preset_grid(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">{@label}</label>
|
||||||
|
<div class="theme-presets">
|
||||||
|
<%= for {preset_name, description} <- @presets do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "apply_preset"}
|
||||||
|
phx-value-preset={preset_name}
|
||||||
|
class={["theme-preset", @active_preset == preset_name && "theme-preset-active"]}
|
||||||
|
>
|
||||||
|
<div class="theme-preset-name">{preset_name}</div>
|
||||||
|
<div class="theme-preset-desc">{description}</div>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the colour mood chip selector.
|
||||||
|
"""
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def mood_chips(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Colour mood</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="mood"
|
||||||
|
phx-value-setting_value={mood}
|
||||||
|
class={["theme-chip", @theme_settings.mood == mood && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{mood}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the font style chip selector.
|
||||||
|
"""
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def typography_chips(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Font style</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="typography"
|
||||||
|
phx-value-setting_value={typo}
|
||||||
|
class={["theme-chip", @theme_settings.typography == typo && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{typo}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the corner style chip selector.
|
||||||
|
"""
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def shape_chips(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">Corner style</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="shape"
|
||||||
|
phx-value-setting_value={shape}
|
||||||
|
class={["theme-chip", @theme_settings.shape == shape && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{shape}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Accent Colors ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders an accent colour picker with ColorSync hook.
|
||||||
|
"""
|
||||||
|
attr :field, :string, required: true
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :value, :string, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def color_picker(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-section">
|
||||||
|
<label class="theme-section-label">{@label}</label>
|
||||||
|
<form
|
||||||
|
id={"#{@field}-color-form"}
|
||||||
|
phx-change={@event_prefix <> "update_color"}
|
||||||
|
phx-value-field={@field}
|
||||||
|
phx-hook="ColorSync"
|
||||||
|
>
|
||||||
|
<div class="theme-color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id={"#{@field}-color-picker"}
|
||||||
|
name="value"
|
||||||
|
value={@value}
|
||||||
|
class="theme-color-swatch"
|
||||||
|
/>
|
||||||
|
<span class="theme-color-value">{@value}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Compact Mode ───────────────────────────────────────────────────
|
||||||
|
# Quick settings panel for on-site editor.
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the on-site theme editor panel.
|
||||||
|
|
||||||
|
Shows all theme settings including presets, colours, and the full
|
||||||
|
customise accordion with advanced options.
|
||||||
|
"""
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :active_preset, :atom, default: nil
|
||||||
|
attr :presets, :list, default: []
|
||||||
|
attr :site_name, :string, default: ""
|
||||||
|
attr :customise_open, :boolean, default: false
|
||||||
|
attr :event_prefix, :string, default: "theme_"
|
||||||
|
|
||||||
|
def compact_editor(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="editor-theme-content">
|
||||||
|
<%= if @theme_settings do %>
|
||||||
|
<.shop_name_input site_name={@site_name} event_prefix={@event_prefix} />
|
||||||
|
<.preset_grid presets={@presets} active_preset={@active_preset} event_prefix={@event_prefix} />
|
||||||
|
|
||||||
|
<.color_picker
|
||||||
|
field="accent_color"
|
||||||
|
label="Accent colour"
|
||||||
|
value={@theme_settings.accent_color}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
<.color_picker
|
||||||
|
field="secondary_accent_color"
|
||||||
|
label="Hover colour"
|
||||||
|
value={@theme_settings.secondary_accent_color}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
<.color_picker
|
||||||
|
field="sale_color"
|
||||||
|
label="Sale colour"
|
||||||
|
value={@theme_settings.sale_color}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.customise_accordion
|
||||||
|
theme_settings={@theme_settings}
|
||||||
|
customise_open={@customise_open}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="theme-section">
|
||||||
|
<p class="admin-text-secondary">
|
||||||
|
For logo and header image uploads, <a href="/admin/theme" class="admin-link">visit the full theme editor</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="admin-text-secondary">Loading theme settings...</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Full Customise Accordion ───────────────────────────────────────
|
||||||
|
# Advanced settings groups for admin theme page.
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the customise accordion with all advanced settings groups.
|
||||||
|
"""
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :customise_open, :boolean, default: false
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
def customise_accordion(assigns) do
|
||||||
|
~H"""
|
||||||
|
<details class="theme-customise" id="customise-section" open={@customise_open}>
|
||||||
|
<summary class="theme-customise-summary" phx-click={@event_prefix <> "toggle_customise"}>
|
||||||
|
<span class="theme-customise-label">Customise</span>
|
||||||
|
<svg
|
||||||
|
class="theme-customise-chevron"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="theme-customise-body">
|
||||||
|
<.typography_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
<.colours_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
<.layout_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
<.shape_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
<.products_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
<.product_page_group theme_settings={@theme_settings} event_prefix={@event_prefix} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Setting Groups ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp typography_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20"></line>
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="theme-group-title">Typography</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Font style</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="typography"
|
||||||
|
phx-value-setting_value={typo}
|
||||||
|
class={["theme-chip", @theme_settings.typography == typo && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{typo}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Font size</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"small", "Small"}, {"medium", "Medium"}, {"large", "Large"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="font_size"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={["theme-chip", @theme_settings.font_size == value && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Heading weight</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"regular", "Regular"}, {"medium", "Medium"}, {"bold", "Bold"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="heading_weight"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={["theme-chip", @theme_settings.heading_weight == value && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp colours_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="theme-group-title">Colours</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Colour mood</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="mood"
|
||||||
|
phx-value-setting_value={mood}
|
||||||
|
class={["theme-chip", @theme_settings.mood == mood && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{mood}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp layout_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||||
|
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="theme-group-title">Layout</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Product grid</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for cols <- ["2", "3", "4"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="grid_columns"
|
||||||
|
phx-value-setting_value={cols}
|
||||||
|
class={["theme-chip", @theme_settings.grid_columns == cols && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{cols} columns
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Density</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for density <- ["spacious", "balanced", "compact"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="density"
|
||||||
|
phx-value-setting_value={density}
|
||||||
|
class={["theme-chip", @theme_settings.density == density && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{density}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Header layout</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for layout <- ["standard", "centered", "left"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="header_layout"
|
||||||
|
phx-value-setting_value={layout}
|
||||||
|
class={["theme-chip", @theme_settings.header_layout == layout && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{layout}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="announcement_bar"
|
||||||
|
label="Announcement bar"
|
||||||
|
checked={@theme_settings.announcement_bar}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="sticky_header"
|
||||||
|
label="Sticky header"
|
||||||
|
checked={@theme_settings.sticky_header}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp shape_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group-flush">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
<span class="theme-group-title">Shape</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Corner style</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="shape"
|
||||||
|
phx-value-setting_value={shape}
|
||||||
|
class={["theme-chip", @theme_settings.shape == shape && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{shape}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Card shadow</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"none", "None"}, {"sm", "Subtle"}, {"md", "Medium"}, {"lg", "Strong"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="card_shadow"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={["theme-chip", @theme_settings.card_shadow == value && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Button style</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"filled", "Filled"}, {"outline", "Outline"}, {"soft", "Soft"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="button_style"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={["theme-chip", @theme_settings.button_style == value && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp products_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span class="theme-group-title">Products</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Content width</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for width <- ["contained", "wide", "full"] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="layout_width"
|
||||||
|
phx-value-setting_value={width}
|
||||||
|
class={["theme-chip", @theme_settings.layout_width == width && "theme-chip-active"]}
|
||||||
|
>
|
||||||
|
{width}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Image aspect ratio</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"square", "Square"}, {"portrait", "Portrait"}, {"landscape", "Landscape"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="image_aspect_ratio"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={[
|
||||||
|
"theme-chip",
|
||||||
|
@theme_settings.image_aspect_ratio == value && "theme-chip-active"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="theme-section-label">Product text alignment</label>
|
||||||
|
<div class="theme-chips">
|
||||||
|
<%= for {value, label} <- [{"left", "Left"}, {"center", "Centre"}] do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click={@event_prefix <> "update_setting"}
|
||||||
|
phx-value-field="product_text_align"
|
||||||
|
phx-value-setting_value={value}
|
||||||
|
class={[
|
||||||
|
"theme-chip",
|
||||||
|
@theme_settings.product_text_align == value && "theme-chip-active"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="hover_image"
|
||||||
|
label="Second image on hover"
|
||||||
|
checked={@theme_settings.hover_image}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="show_prices"
|
||||||
|
label="Show prices"
|
||||||
|
checked={@theme_settings.show_prices}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :theme_settings, :map, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp product_page_group(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-group-flush">
|
||||||
|
<div class="theme-group-header">
|
||||||
|
<svg
|
||||||
|
class="theme-group-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="theme-group-title">Product page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="pdp_trust_badges"
|
||||||
|
label="Trust badges"
|
||||||
|
checked={@theme_settings.pdp_trust_badges}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="pdp_reviews"
|
||||||
|
label="Reviews section"
|
||||||
|
checked={@theme_settings.pdp_reviews}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_field
|
||||||
|
field="pdp_related_products"
|
||||||
|
label="Related products"
|
||||||
|
checked={@theme_settings.pdp_related_products}
|
||||||
|
event_prefix={@event_prefix}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helper Components ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
attr :field, :string, required: true
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :checked, :boolean, required: true
|
||||||
|
attr :event_prefix, :string, default: ""
|
||||||
|
|
||||||
|
defp toggle_field(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="theme-field">
|
||||||
|
<label class="admin-check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={@checked}
|
||||||
|
phx-click={@event_prefix <> "toggle_setting"}
|
||||||
|
phx-value-field={@field}
|
||||||
|
class="admin-checkbox admin-checkbox-sm"
|
||||||
|
/>
|
||||||
|
<span class="theme-check-text">{@label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -522,561 +522,39 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Presets Section -->
|
<!-- Presets Section -->
|
||||||
<div class="theme-section">
|
<.preset_grid
|
||||||
<label class="theme-section-label">Start with a preset</label>
|
presets={@presets_with_descriptions}
|
||||||
<div class="theme-presets">
|
active_preset={@active_preset}
|
||||||
<%= for {preset_name, description} <- @presets_with_descriptions do %>
|
event_prefix=""
|
||||||
<button
|
label="Start with a preset"
|
||||||
type="button"
|
/>
|
||||||
phx-click="apply_preset"
|
|
||||||
phx-value-preset={preset_name}
|
|
||||||
class={[
|
|
||||||
"theme-preset",
|
|
||||||
@active_preset == preset_name && "theme-preset-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div class="theme-preset-name">{preset_name}</div>
|
|
||||||
<div class="theme-preset-desc">{description}</div>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Accent Colors -->
|
<!-- Accent Colors -->
|
||||||
<div class="theme-section">
|
<.color_picker
|
||||||
<label class="theme-section-label">Accent colour</label>
|
field="accent_color"
|
||||||
<form
|
label="Accent colour"
|
||||||
id="accent-color-form"
|
|
||||||
phx-change="update_color"
|
|
||||||
phx-value-field="accent_color"
|
|
||||||
phx-hook="ColorSync"
|
|
||||||
>
|
|
||||||
<div class="theme-color-row">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
id="accent-color-picker"
|
|
||||||
name="value"
|
|
||||||
value={@theme_settings.accent_color}
|
value={@theme_settings.accent_color}
|
||||||
class="theme-color-swatch"
|
event_prefix=""
|
||||||
/>
|
/>
|
||||||
<span class="theme-color-value">{@theme_settings.accent_color}</span>
|
<.color_picker
|
||||||
</div>
|
field="secondary_accent_color"
|
||||||
</form>
|
label="Hover colour"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Hover colour</label>
|
|
||||||
<form
|
|
||||||
id="secondary-accent-color-form"
|
|
||||||
phx-change="update_color"
|
|
||||||
phx-value-field="secondary_accent_color"
|
|
||||||
phx-hook="ColorSync"
|
|
||||||
>
|
|
||||||
<div class="theme-color-row">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
id="secondary-accent-color-picker"
|
|
||||||
name="value"
|
|
||||||
value={@theme_settings.secondary_accent_color}
|
value={@theme_settings.secondary_accent_color}
|
||||||
class="theme-color-swatch"
|
event_prefix=""
|
||||||
/>
|
/>
|
||||||
<span class="theme-color-value">{@theme_settings.secondary_accent_color}</span>
|
<.color_picker
|
||||||
</div>
|
field="sale_color"
|
||||||
</form>
|
label="Sale colour"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Sale colour</label>
|
|
||||||
<form
|
|
||||||
id="sale-color-form"
|
|
||||||
phx-change="update_color"
|
|
||||||
phx-value-field="sale_color"
|
|
||||||
phx-hook="ColorSync"
|
|
||||||
>
|
|
||||||
<div class="theme-color-row">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
id="sale-color-picker"
|
|
||||||
name="value"
|
|
||||||
value={@theme_settings.sale_color}
|
value={@theme_settings.sale_color}
|
||||||
class="theme-color-swatch"
|
event_prefix=""
|
||||||
/>
|
/>
|
||||||
<span class="theme-color-value">{@theme_settings.sale_color}</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Customise Section -->
|
<!-- Customise Section -->
|
||||||
<details
|
<.customise_accordion
|
||||||
class="theme-customise"
|
theme_settings={@theme_settings}
|
||||||
id="customise-section"
|
customise_open={@customise_open}
|
||||||
open={@customise_open}
|
event_prefix=""
|
||||||
>
|
|
||||||
<summary class="theme-customise-summary" phx-click="toggle_customise">
|
|
||||||
<span class="theme-customise-label">Customise</span>
|
|
||||||
<svg
|
|
||||||
class="theme-customise-chevron"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="theme-customise-body">
|
|
||||||
<!-- Typography Group -->
|
|
||||||
<div class="theme-group">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
|
||||||
<line x1="9" y1="20" x2="15" y2="20"></line>
|
|
||||||
<line x1="12" y1="4" x2="12" y2="20"></line>
|
|
||||||
</svg>
|
|
||||||
<span class="theme-group-title">Typography</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Font style</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="typography"
|
|
||||||
phx-value-setting_value={typo}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.typography == typo && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{typo}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Font size</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"small", "Small"}, {"medium", "Medium"}, {"large", "Large"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="font_size"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.font_size == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Heading weight</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"regular", "Regular"}, {"medium", "Medium"}, {"bold", "Bold"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="heading_weight"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.heading_weight == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Colours Group -->
|
|
||||||
<div class="theme-group">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
|
||||||
</svg>
|
|
||||||
<span class="theme-group-title">Colours</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Colour mood</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="mood"
|
|
||||||
phx-value-setting_value={mood}
|
|
||||||
class={["theme-chip", @theme_settings.mood == mood && "theme-chip-active"]}
|
|
||||||
>
|
|
||||||
{mood}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Layout Group -->
|
|
||||||
<div class="theme-group">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
|
||||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
|
||||||
</svg>
|
|
||||||
<span class="theme-group-title">Layout</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Product grid</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for cols <- ["2", "3", "4"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="grid_columns"
|
|
||||||
phx-value-setting_value={cols}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.grid_columns == cols && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{cols} columns
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Density</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for density <- ["spacious", "balanced", "compact"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="density"
|
|
||||||
phx-value-setting_value={density}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.density == density && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{density}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Header layout</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for layout <- ["standard", "centered", "left"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="header_layout"
|
|
||||||
phx-value-setting_value={layout}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.header_layout == layout && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{layout}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.announcement_bar}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="announcement_bar"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
/>
|
||||||
<span class="theme-check-text">Announcement bar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.sticky_header}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="sticky_header"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Sticky header</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shape Group -->
|
|
||||||
<div class="theme-group-flush">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
</svg>
|
|
||||||
<span class="theme-group-title">Shape</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Corner style</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="shape"
|
|
||||||
phx-value-setting_value={shape}
|
|
||||||
class={["theme-chip", @theme_settings.shape == shape && "theme-chip-active"]}
|
|
||||||
>
|
|
||||||
{shape}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Card shadow</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"none", "None"}, {"sm", "Subtle"}, {"md", "Medium"}, {"lg", "Strong"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="card_shadow"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.card_shadow == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Button style</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"filled", "Filled"}, {"outline", "Outline"}, {"soft", "Soft"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="button_style"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.button_style == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Products Group -->
|
|
||||||
<div class="theme-group">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<span class="theme-group-title">Products</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Content width</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for width <- ["contained", "wide", "full"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="layout_width"
|
|
||||||
phx-value-setting_value={width}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.layout_width == width && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{width}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Image aspect ratio</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"square", "Square"}, {"portrait", "Portrait"}, {"landscape", "Landscape"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="image_aspect_ratio"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.image_aspect_ratio == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="theme-section-label">Product text alignment</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for {value, label} <- [{"left", "Left"}, {"center", "Centre"}] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="update_setting"
|
|
||||||
phx-value-field="product_text_align"
|
|
||||||
phx-value-setting_value={value}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_settings.product_text_align == value && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.hover_image}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="hover_image"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Second image on hover</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.show_prices}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="show_prices"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Show prices</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Page Group -->
|
|
||||||
<div class="theme-group-flush">
|
|
||||||
<div class="theme-group-header">
|
|
||||||
<svg
|
|
||||||
class="theme-group-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
|
||||||
</svg>
|
|
||||||
<span class="theme-group-title">Product page</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.pdp_trust_badges}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="pdp_trust_badges"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Trust badges</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.pdp_reviews}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="pdp_reviews"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Reviews section</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-field">
|
|
||||||
<label class="admin-check-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={@theme_settings.pdp_related_products}
|
|
||||||
phx-click="toggle_setting"
|
|
||||||
phx-value-field="pdp_related_products"
|
|
||||||
class="admin-checkbox admin-checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="theme-check-text">Related products</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
defmodule BerrypodWeb.Shop.Cart do
|
|
||||||
use BerrypodWeb, :live_view
|
|
||||||
|
|
||||||
alias Berrypod.{Cart, Pages}
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
page = Pages.get_page("cart")
|
|
||||||
{:ok, socket |> assign(:page_title, "Cart") |> assign(:page, page)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
assigns = assign(assigns, :cart_page_subtotal, Cart.calculate_subtotal(assigns.cart_items))
|
|
||||||
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
defmodule BerrypodWeb.Shop.CheckoutSuccess do
|
|
||||||
use BerrypodWeb, :live_view
|
|
||||||
|
|
||||||
alias Berrypod.{Analytics, Orders, Pages}
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(%{"session_id" => session_id}, _session, socket) do
|
|
||||||
order = Orders.get_order_by_stripe_session(session_id)
|
|
||||||
|
|
||||||
# Subscribe to order status updates (webhook may arrive after redirect)
|
|
||||||
if order && connected?(socket) do
|
|
||||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Track purchase event
|
|
||||||
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
|
|
||||||
attrs =
|
|
||||||
BerrypodWeb.AnalyticsHook.attrs(socket)
|
|
||||||
|> Map.merge(%{pathname: "/checkout/success", revenue: order.total})
|
|
||||||
|
|
||||||
Analytics.track_event("purchase", attrs)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Clear the cart after successful checkout
|
|
||||||
socket =
|
|
||||||
if order && connected?(socket) do
|
|
||||||
BerrypodWeb.CartHook.broadcast_and_update(socket, [])
|
|
||||||
else
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
page = Pages.get_page("checkout_success")
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:page_title, "Order confirmed")
|
|
||||||
|> assign(:order, order)
|
|
||||||
|> assign(:page, page)
|
|
||||||
|
|
||||||
{:ok, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
{:ok, redirect(socket, to: ~p"/")}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:order_paid, order}, socket) do
|
|
||||||
{:noreply, assign(socket, :order, order)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -6,6 +6,11 @@ defmodule BerrypodWeb.Shop.ComingSoon do
|
|||||||
{:ok, assign(socket, :page_title, "Coming soon")}
|
{:ok, assign(socket, :page_title, "Coming soon")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(_params, _uri, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|||||||
154
lib/berrypod_web/live/shop/page.ex
Normal file
154
lib/berrypod_web/live/shop/page.ex
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.Page do
|
||||||
|
@moduledoc """
|
||||||
|
Unified shop LiveView that handles all shop pages.
|
||||||
|
|
||||||
|
Using a single LiveView enables `patch` navigation between pages,
|
||||||
|
preserving socket state (including editor state) across transitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
|
alias BerrypodWeb.Shop.Pages
|
||||||
|
|
||||||
|
# Map live_action atoms to page handler modules
|
||||||
|
@page_modules %{
|
||||||
|
home: Pages.Home,
|
||||||
|
product: Pages.Product,
|
||||||
|
collection: Pages.Collection,
|
||||||
|
cart: Pages.Cart,
|
||||||
|
contact: Pages.Contact,
|
||||||
|
search: Pages.Search,
|
||||||
|
orders: Pages.Orders,
|
||||||
|
order_detail: Pages.OrderDetail,
|
||||||
|
checkout_success: Pages.CheckoutSuccess,
|
||||||
|
custom_page: Pages.CustomPage,
|
||||||
|
# Content pages all use the same module
|
||||||
|
about: Pages.Content,
|
||||||
|
delivery: Pages.Content,
|
||||||
|
privacy: Pages.Content,
|
||||||
|
terms: Pages.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages that need session data passed to init
|
||||||
|
@session_pages [:orders, :order_detail]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, session, socket) do
|
||||||
|
# Store session for pages that need it (orders, order_detail)
|
||||||
|
{:ok, assign(socket, :_session, session)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, uri, socket) do
|
||||||
|
action = socket.assigns.live_action
|
||||||
|
prev_action = socket.assigns[:_current_page_action]
|
||||||
|
module = @page_modules[action]
|
||||||
|
|
||||||
|
# Clean up previous page if needed (e.g., unsubscribe from PubSub)
|
||||||
|
socket = maybe_cleanup_previous_page(socket, prev_action)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
if action != prev_action do
|
||||||
|
# Page type changed - call init
|
||||||
|
socket = assign(socket, :_current_page_action, action)
|
||||||
|
|
||||||
|
result =
|
||||||
|
if action in @session_pages do
|
||||||
|
module.init(socket, params, uri, socket.assigns._session)
|
||||||
|
else
|
||||||
|
module.init(socket, params, uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:noreply, socket} -> socket
|
||||||
|
{:redirect, socket} -> socket
|
||||||
|
end
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
# After page init, sync editor state if editing and page changed
|
||||||
|
socket = maybe_sync_editing_blocks(socket)
|
||||||
|
|
||||||
|
# Always call handle_params for URL changes
|
||||||
|
case module.handle_params(params, uri, socket) do
|
||||||
|
{:noreply, socket} -> {:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If editing and we navigated to a different page, reload editing_blocks
|
||||||
|
defp maybe_sync_editing_blocks(socket) do
|
||||||
|
page = socket.assigns[:page]
|
||||||
|
editing = socket.assigns[:editing]
|
||||||
|
editor_page_slug = socket.assigns[:editor_page_slug]
|
||||||
|
|
||||||
|
if editing && page && page.slug != editor_page_slug do
|
||||||
|
# Page changed while editing - reload editing state for the new page
|
||||||
|
allowed = Berrypod.Pages.BlockTypes.allowed_for(page.slug)
|
||||||
|
at_defaults = Berrypod.Pages.Defaults.matches_defaults?(page.slug, page.blocks)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:editing_blocks, page.blocks)
|
||||||
|
|> assign(:editor_page_slug, page.slug)
|
||||||
|
|> assign(:editor_dirty, false)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|
|> assign(:editor_history, [])
|
||||||
|
|> assign(:editor_future, [])
|
||||||
|
|> assign(:editor_expanded, MapSet.new())
|
||||||
|
|> assign(:editor_allowed_blocks, allowed)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event(event, params, socket) do
|
||||||
|
module = @page_modules[socket.assigns.live_action]
|
||||||
|
|
||||||
|
case module.handle_event(event, params, socket) do
|
||||||
|
:cont ->
|
||||||
|
# Event not handled by page module, let hooks handle it
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:noreply, socket} ->
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(msg, socket) do
|
||||||
|
module = @page_modules[socket.assigns.live_action]
|
||||||
|
|
||||||
|
# Check if the module defines handle_info
|
||||||
|
if function_exported?(module, :handle_info, 2) do
|
||||||
|
case module.handle_info(msg, socket) do
|
||||||
|
:cont -> {:noreply, socket}
|
||||||
|
{:noreply, socket} -> {:noreply, socket}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
# Cart page needs extra assigns computed at render time
|
||||||
|
assigns =
|
||||||
|
if assigns.live_action == :cart do
|
||||||
|
Pages.Cart.compute_assigns(assigns)
|
||||||
|
else
|
||||||
|
assigns
|
||||||
|
end
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clean up previous page state when transitioning
|
||||||
|
defp maybe_cleanup_previous_page(socket, :checkout_success) do
|
||||||
|
Pages.CheckoutSuccess.cleanup(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_cleanup_previous_page(socket, _), do: socket
|
||||||
|
end
|
||||||
31
lib/berrypod_web/live/shop/pages/cart.ex
Normal file
31
lib/berrypod_web/live/shop/pages/cart.ex
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.Pages.Cart do
|
||||||
|
@moduledoc """
|
||||||
|
Cart page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
|
alias Berrypod.Pages
|
||||||
|
|
||||||
|
def init(socket, _params, _uri) do
|
||||||
|
page = Pages.get_page("cart")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Cart")
|
||||||
|
|> assign(:page, page)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_params(_params, _uri, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
|
||||||
|
# Called from render to compute the subtotal
|
||||||
|
def compute_assigns(assigns) do
|
||||||
|
Map.put(assigns, :cart_page_subtotal, Berrypod.Cart.calculate_subtotal(assigns.cart_items))
|
||||||
|
end
|
||||||
|
end
|
||||||
82
lib/berrypod_web/live/shop/pages/checkout_success.ex
Normal file
82
lib/berrypod_web/live/shop/pages/checkout_success.ex
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
|
||||||
|
@moduledoc """
|
||||||
|
Checkout success page handler for the unified Shop.Page LiveView.
|
||||||
|
Handles PubSub subscription for order status updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [connected?: 1, redirect: 2]
|
||||||
|
|
||||||
|
alias Berrypod.{Analytics, Orders, Pages}
|
||||||
|
|
||||||
|
def init(socket, %{"session_id" => session_id}, _uri) do
|
||||||
|
order = Orders.get_order_by_stripe_session(session_id)
|
||||||
|
|
||||||
|
# Subscribe to order status updates (webhook may arrive after redirect)
|
||||||
|
if order && connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Track purchase event
|
||||||
|
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
|
||||||
|
attrs =
|
||||||
|
BerrypodWeb.AnalyticsHook.attrs(socket)
|
||||||
|
|> Map.merge(%{pathname: "/checkout/success", revenue: order.total})
|
||||||
|
|
||||||
|
Analytics.track_event("purchase", attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear the cart after successful checkout
|
||||||
|
socket =
|
||||||
|
if order && connected?(socket) do
|
||||||
|
BerrypodWeb.CartHook.broadcast_and_update(socket, [])
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
# Track subscription for cleanup when leaving this page
|
||||||
|
socket =
|
||||||
|
if order do
|
||||||
|
assign(socket, :checkout_order_subscription, "order:#{order.id}:status")
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
page = Pages.get_page("checkout_success")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Order confirmed")
|
||||||
|
|> assign(:order, order)
|
||||||
|
|> assign(:page, page)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def init(socket, _params, _uri) do
|
||||||
|
{:redirect, redirect(socket, to: "/")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_params(_params, _uri, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
|
||||||
|
def handle_info({:order_paid, order}, socket) do
|
||||||
|
{:noreply, assign(socket, :order, order)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_msg, _socket), do: :cont
|
||||||
|
|
||||||
|
# Called when leaving this page to clean up subscription
|
||||||
|
def cleanup(socket) do
|
||||||
|
if topic = socket.assigns[:checkout_order_subscription] do
|
||||||
|
Phoenix.PubSub.unsubscribe(Berrypod.PubSub, topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:checkout_order_subscription, nil)
|
||||||
|
|> assign(:order, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,5 +1,10 @@
|
|||||||
defmodule BerrypodWeb.Shop.Collection do
|
defmodule BerrypodWeb.Shop.Pages.Collection do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Collection page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
|
||||||
|
|
||||||
alias Berrypod.{Pages, Pagination, Products}
|
alias Berrypod.{Pages, Pagination, Products}
|
||||||
|
|
||||||
@ -12,8 +17,7 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
{"name_desc", "Name: Z-A"}
|
{"name_desc", "Name: Z-A"}
|
||||||
]
|
]
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri) do
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
page = Pages.get_page("collection")
|
page = Pages.get_page("collection")
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
@ -22,17 +26,16 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
|> assign(:sort_options, @sort_options)
|
|> assign(:sort_options, @sort_options)
|
||||||
|> assign(:current_sort, "featured")
|
|> assign(:current_sort, "featured")
|
||||||
|
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
||||||
sort = params["sort"] || "featured"
|
sort = params["sort"] || "featured"
|
||||||
page_num = Pagination.parse_page(params)
|
page_num = Pagination.parse_page(params)
|
||||||
|
|
||||||
case load_collection(slug, sort, page_num) do
|
case load_collection(slug, sort, page_num) do
|
||||||
{:ok, title, category, pagination} ->
|
{:ok, title, category, pagination} ->
|
||||||
{:noreply,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, title)
|
|> assign(:page_title, title)
|
||||||
|> assign(:page_description, collection_description(title))
|
|> assign(:page_description, collection_description(title))
|
||||||
@ -42,16 +45,33 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
|> assign(:current_category, category)
|
|> assign(:current_category, category)
|
||||||
|> assign(:current_sort, sort)
|
|> assign(:current_sort, sort)
|
||||||
|> assign(:pagination, pagination)
|
|> assign(:pagination, pagination)
|
||||||
|> assign(:products, pagination.items)}
|
|> assign(:products, pagination.items)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
:not_found ->
|
:not_found ->
|
||||||
{:noreply,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, "Collection not found")
|
|> put_flash(:error, "Collection not found")
|
||||||
|> push_navigate(to: ~p"/collections/all")}
|
|> push_navigate(to: "/collections/all")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("sort_changed", %{"sort" => sort}, socket) do
|
||||||
|
slug =
|
||||||
|
case socket.assigns.current_category do
|
||||||
|
nil -> "all"
|
||||||
|
:sale -> "sale"
|
||||||
|
category -> category.slug
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
|
||||||
defp load_collection("all", sort, page) do
|
defp load_collection("all", sort, page) do
|
||||||
pagination = Products.list_visible_products_paginated(sort: sort, page: page)
|
pagination = Products.list_visible_products_paginated(sort: sort, page: page)
|
||||||
{:ok, "All Products", nil, pagination}
|
{:ok, "All Products", nil, pagination}
|
||||||
@ -79,26 +99,7 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("sort_changed", %{"sort" => sort}, socket) do
|
|
||||||
slug =
|
|
||||||
case socket.assigns.current_category do
|
|
||||||
nil -> "all"
|
|
||||||
:sale -> "sale"
|
|
||||||
category -> category.slug
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp collection_description("All Products"), do: "Browse our full range of products."
|
defp collection_description("All Products"), do: "Browse our full range of products."
|
||||||
defp collection_description("Sale"), do: "Browse our current sale items."
|
defp collection_description("Sale"), do: "Browse our current sale items."
|
||||||
defp collection_description(title), do: "Browse our #{String.downcase(title)} collection."
|
defp collection_description(title), do: "Browse our #{String.downcase(title)} collection."
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@ -1,16 +1,20 @@
|
|||||||
defmodule BerrypodWeb.Shop.Contact do
|
defmodule BerrypodWeb.Shop.Pages.Contact do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Contact page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3]
|
||||||
|
|
||||||
alias Berrypod.{ContactNotifier, Orders}
|
alias Berrypod.{ContactNotifier, Orders}
|
||||||
alias Berrypod.Orders.OrderNotifier
|
alias Berrypod.Orders.OrderNotifier
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias BerrypodWeb.OrderLookupController
|
alias BerrypodWeb.OrderLookupController
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri) do
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
page = Pages.get_page("contact")
|
page = Pages.get_page("contact")
|
||||||
|
|
||||||
{:ok,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Contact")
|
|> assign(:page_title, "Contact")
|
||||||
|> assign(
|
|> assign(
|
||||||
@ -19,10 +23,15 @@ defmodule BerrypodWeb.Shop.Contact do
|
|||||||
)
|
)
|
||||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")
|
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")
|
||||||
|> assign(:tracking_state, :idle)
|
|> assign(:tracking_state, :idle)
|
||||||
|> assign(:page, page)}
|
|> assign(:page, page)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_params(_params, _uri, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("lookup_orders", %{"email" => email}, socket) do
|
def handle_event("lookup_orders", %{"email" => email}, socket) do
|
||||||
orders = Orders.list_orders_by_email(email)
|
orders = Orders.list_orders_by_email(email)
|
||||||
|
|
||||||
@ -31,7 +40,7 @@ defmodule BerrypodWeb.Shop.Contact do
|
|||||||
:not_found
|
:not_found
|
||||||
else
|
else
|
||||||
token = OrderLookupController.generate_token(email)
|
token = OrderLookupController.generate_token(email)
|
||||||
link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}"
|
link = BerrypodWeb.Endpoint.url() <> "/orders/verify/#{token}"
|
||||||
OrderNotifier.deliver_order_lookup(email, link)
|
OrderNotifier.deliver_order_lookup(email, link)
|
||||||
:sent
|
:sent
|
||||||
end
|
end
|
||||||
@ -39,14 +48,13 @@ defmodule BerrypodWeb.Shop.Contact do
|
|||||||
{:noreply, assign(socket, :tracking_state, state)}
|
{:noreply, assign(socket, :tracking_state, state)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("send_contact", params, socket) do
|
def handle_event("send_contact", params, socket) do
|
||||||
case ContactNotifier.deliver_contact_message(params) do
|
case ContactNotifier.deliver_contact_message(params) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, "Message sent! We'll get back to you soon.")
|
|> put_flash(:info, "Message sent! We'll get back to you soon.")
|
||||||
|> push_navigate(to: ~p"/contact")}
|
|> push_navigate(to: "/contact")}
|
||||||
|
|
||||||
{:error, :invalid_params} ->
|
{:error, :invalid_params} ->
|
||||||
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")}
|
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")}
|
||||||
@ -56,15 +64,9 @@ defmodule BerrypodWeb.Shop.Contact do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("reset_tracking", _params, socket) do
|
def handle_event("reset_tracking", _params, socket) do
|
||||||
{:noreply, assign(socket, :tracking_state, :idle)}
|
{:noreply, assign(socket, :tracking_state, :idle)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@ -1,20 +1,25 @@
|
|||||||
defmodule BerrypodWeb.Shop.Content do
|
defmodule BerrypodWeb.Shop.Pages.Content do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Content page handler for the unified Shop.Page LiveView.
|
||||||
|
Handles about, delivery, privacy, and terms pages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
|
||||||
alias Berrypod.LegalPages
|
alias Berrypod.LegalPages
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
alias Berrypod.Theme.PreviewData
|
alias Berrypod.Theme.PreviewData
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri) do
|
||||||
def mount(_params, _session, socket) do
|
# Content pages load in handle_params based on live_action
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(_params, _uri, socket) do
|
def handle_params(_params, _uri, socket) do
|
||||||
slug = to_string(socket.assigns.live_action)
|
action = socket.assigns.live_action
|
||||||
|
slug = to_string(action)
|
||||||
page = Pages.get_page(slug)
|
page = Pages.get_page(slug)
|
||||||
{seo, content_blocks} = page_config(socket.assigns.live_action)
|
{seo, content_blocks} = page_config(action)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@ -25,19 +30,14 @@ defmodule BerrypodWeb.Shop.Content do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns {seo_assigns, content_blocks} for each content page
|
# Returns {seo_assigns, content_blocks} for each content page
|
||||||
defp page_config(:about) do
|
defp page_config(:about) do
|
||||||
{
|
{
|
||||||
%{
|
%{
|
||||||
page_title: "About",
|
page_title: "About",
|
||||||
page_description: "Your story goes here \u2013 this is sample content for the demo shop",
|
page_description: "Your story goes here – this is sample content for the demo shop",
|
||||||
og_url: BerrypodWeb.Endpoint.url() <> "/about"
|
og_url: BerrypodWeb.Endpoint.url() <> "/about"
|
||||||
},
|
},
|
||||||
PreviewData.about_content()
|
PreviewData.about_content()
|
||||||
@ -1,14 +1,17 @@
|
|||||||
defmodule BerrypodWeb.Shop.CustomPage do
|
defmodule BerrypodWeb.Shop.Pages.CustomPage do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Custom (CMS) page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri) do
|
||||||
def mount(_params, _session, socket) do
|
# Custom pages load in handle_params based on slug
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(%{"slug" => slug}, _uri, socket) do
|
def handle_params(%{"slug" => slug}, _uri, socket) do
|
||||||
page = Pages.get_page(slug)
|
page = Pages.get_page(slug)
|
||||||
|
|
||||||
@ -38,12 +41,7 @@ defmodule BerrypodWeb.Shop.CustomPage do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp record_broken_url(path) do
|
defp record_broken_url(path) do
|
||||||
prior_hits = Berrypod.Analytics.count_pageviews_for_path(path)
|
prior_hits = Berrypod.Analytics.count_pageviews_for_path(path)
|
||||||
@ -1,10 +1,13 @@
|
|||||||
defmodule BerrypodWeb.Shop.Home do
|
defmodule BerrypodWeb.Shop.Pages.Home do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Home page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri) do
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
page = Pages.get_page("home")
|
page = Pages.get_page("home")
|
||||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||||
|
|
||||||
@ -30,13 +33,12 @@ defmodule BerrypodWeb.Shop.Home do
|
|||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|> assign(extra)
|
|> assign(extra)
|
||||||
|
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_params(_params, _uri, socket) do
|
||||||
def render(assigns) do
|
{:noreply, socket}
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
end
|
end
|
||||||
@ -1,12 +1,16 @@
|
|||||||
defmodule BerrypodWeb.Shop.OrderDetail do
|
defmodule BerrypodWeb.Shop.Pages.OrderDetail do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Order detail page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [push_navigate: 2]
|
||||||
|
|
||||||
alias Berrypod.{Orders, Pages}
|
alias Berrypod.{Orders, Pages}
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
alias Berrypod.Products.ProductImage
|
alias Berrypod.Products.ProductImage
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri, session) do
|
||||||
def mount(_params, session, socket) do
|
|
||||||
page = Pages.get_page("order_detail")
|
page = Pages.get_page("order_detail")
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
@ -14,10 +18,9 @@ defmodule BerrypodWeb.Shop.OrderDetail do
|
|||||||
|> assign(:lookup_email, session["order_lookup_email"])
|
|> assign(:lookup_email, session["order_lookup_email"])
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(%{"order_number" => order_number}, _uri, socket) do
|
def handle_params(%{"order_number" => order_number}, _uri, socket) do
|
||||||
email = socket.assigns.lookup_email
|
email = socket.assigns.lookup_email
|
||||||
|
|
||||||
@ -43,20 +46,17 @@ defmodule BerrypodWeb.Shop.OrderDetail do
|
|||||||
{id, %{thumb: thumb, slug: slug}}
|
{id, %{thumb: thumb, slug: slug}}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:noreply,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Order #{order_number}")
|
|> assign(:page_title, "Order #{order_number}")
|
||||||
|> assign(:order, order)
|
|> assign(:order, order)
|
||||||
|> assign(:thumbnails, thumbnails)}
|
|> assign(:thumbnails, thumbnails)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
else
|
else
|
||||||
{:noreply, push_navigate(socket, to: ~p"/orders")}
|
{:noreply, push_navigate(socket, to: "/orders")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@ -1,10 +1,13 @@
|
|||||||
defmodule BerrypodWeb.Shop.Orders do
|
defmodule BerrypodWeb.Shop.Pages.Orders do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Orders list page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias Berrypod.{Orders, Pages}
|
alias Berrypod.{Orders, Pages}
|
||||||
|
|
||||||
@impl true
|
def init(socket, _params, _uri, session) do
|
||||||
def mount(_params, session, socket) do
|
|
||||||
email = session["order_lookup_email"]
|
email = session["order_lookup_email"]
|
||||||
page = Pages.get_page("orders")
|
page = Pages.get_page("orders")
|
||||||
|
|
||||||
@ -21,16 +24,12 @@ defmodule BerrypodWeb.Shop.Orders do
|
|||||||
assign(socket, :orders, nil)
|
assign(socket, :orders, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
def handle_params(_params, _uri, socket) do
|
||||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
end
|
end
|
||||||
@ -1,16 +1,20 @@
|
|||||||
defmodule BerrypodWeb.Shop.ProductShow do
|
defmodule BerrypodWeb.Shop.Pages.Product do
|
||||||
use BerrypodWeb, :live_view
|
@moduledoc """
|
||||||
|
Product detail page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
|
||||||
|
|
||||||
alias Berrypod.{Analytics, Cart, Pages}
|
alias Berrypod.{Analytics, Cart, Pages}
|
||||||
alias Berrypod.Images.Optimizer
|
alias Berrypod.Images.Optimizer
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
alias Berrypod.Products.{Product, ProductImage}
|
alias Berrypod.Products.{Product, ProductImage}
|
||||||
|
|
||||||
@impl true
|
def init(socket, %{"id" => slug}, _uri) do
|
||||||
def mount(%{"id" => slug}, _session, socket) do
|
|
||||||
case Products.get_visible_product(slug) do
|
case Products.get_visible_product(slug) do
|
||||||
nil ->
|
nil ->
|
||||||
{:ok, push_navigate(socket, to: ~p"/collections/all")}
|
{:noreply, push_navigate(socket, to: "/collections/all")}
|
||||||
|
|
||||||
product ->
|
product ->
|
||||||
all_images =
|
all_images =
|
||||||
@ -61,11 +65,10 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
# Block data loaders (related_products, reviews) run after product is assigned
|
# Block data loaders (related_products, reviews) run after product is assigned
|
||||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||||
|
|
||||||
{:ok, assign(socket, extra)}
|
{:noreply, assign(socket, extra)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
if socket.assigns[:product] do
|
if socket.assigns[:product] do
|
||||||
{:noreply, apply_variant_params(params, socket)}
|
{:noreply, apply_variant_params(params, socket)}
|
||||||
@ -74,6 +77,50 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("increment_quantity", _params, socket) do
|
||||||
|
quantity = min(socket.assigns.quantity + 1, 99)
|
||||||
|
{:noreply, assign(socket, :quantity, quantity)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("decrement_quantity", _params, socket) do
|
||||||
|
quantity = max(socket.assigns.quantity - 1, 1)
|
||||||
|
{:noreply, assign(socket, :quantity, quantity)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("add_to_cart", _params, socket) do
|
||||||
|
variant = socket.assigns.selected_variant
|
||||||
|
|
||||||
|
if variant do
|
||||||
|
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
||||||
|
|
||||||
|
if socket.assigns[:analytics_visitor_hash] do
|
||||||
|
Analytics.track_event(
|
||||||
|
"add_to_cart",
|
||||||
|
Map.put(
|
||||||
|
BerrypodWeb.AnalyticsHook.attrs(socket),
|
||||||
|
:pathname,
|
||||||
|
"/products/#{socket.assigns.product.slug}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|
||||||
|
|> assign(:quantity, 1)
|
||||||
|
|> assign(:cart_drawer_open, true)
|
||||||
|
|> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
|
||||||
|
# ── Variant selection logic ──────────────────────────────────────────
|
||||||
|
|
||||||
defp apply_variant_params(params, socket) do
|
defp apply_variant_params(params, socket) do
|
||||||
%{option_types: option_types, variants: variants, product: product, all_images: all_images} =
|
%{option_types: option_types, variants: variants, product: product, all_images: all_images} =
|
||||||
socket.assigns
|
socket.assigns
|
||||||
@ -149,7 +196,7 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
opt_type.values
|
opt_type.values
|
||||||
|> Enum.map(fn value ->
|
|> Enum.map(fn value ->
|
||||||
params = Map.put(selected_options, opt_type.name, value.title)
|
params = Map.put(selected_options, opt_type.name, value.title)
|
||||||
{value.title, ~p"/products/#{slug}?#{params}"}
|
{value.title, "/products/#{slug}?#{URI.encode_query(params)}"}
|
||||||
end)
|
end)
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
|
|
||||||
@ -221,55 +268,7 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
|> Enum.map(& &1.url)
|
|> Enum.map(& &1.url)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
# ── JSON-LD and meta helpers ─────────────────────────────────────────
|
||||||
def handle_event("increment_quantity", _params, socket) do
|
|
||||||
quantity = min(socket.assigns.quantity + 1, 99)
|
|
||||||
{:noreply, assign(socket, :quantity, quantity)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("decrement_quantity", _params, socket) do
|
|
||||||
quantity = max(socket.assigns.quantity - 1, 1)
|
|
||||||
{:noreply, assign(socket, :quantity, quantity)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("add_to_cart", _params, socket) do
|
|
||||||
variant = socket.assigns.selected_variant
|
|
||||||
|
|
||||||
if variant do
|
|
||||||
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
|
||||||
|
|
||||||
if socket.assigns[:analytics_visitor_hash] do
|
|
||||||
Analytics.track_event(
|
|
||||||
"add_to_cart",
|
|
||||||
Map.put(
|
|
||||||
BerrypodWeb.AnalyticsHook.attrs(socket),
|
|
||||||
:pathname,
|
|
||||||
"/products/#{socket.assigns.product.slug}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|
|
||||||
|> assign(:quantity, 1)
|
|
||||||
|> assign(:cart_drawer_open, true)
|
|
||||||
|> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp product_json_ld(product, url, image, base) do
|
defp product_json_ld(product, url, image, base) do
|
||||||
category_slug =
|
category_slug =
|
||||||
39
lib/berrypod_web/live/shop/pages/search.ex
Normal file
39
lib/berrypod_web/live/shop/pages/search.ex
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.Pages.Search do
|
||||||
|
@moduledoc """
|
||||||
|
Search page handler for the unified Shop.Page LiveView.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [push_patch: 2]
|
||||||
|
|
||||||
|
alias Berrypod.{Pages, Search}
|
||||||
|
|
||||||
|
def init(socket, _params, _uri) do
|
||||||
|
page = Pages.get_page("search")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Search")
|
||||||
|
|> assign(:page, page)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_params(params, _uri, socket) do
|
||||||
|
query = params["q"] || ""
|
||||||
|
results = if query != "", do: Search.search(query), else: []
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:search_page_query, query)
|
||||||
|
|> assign(:search_page_results, results)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("search_submit", %{"q" => query}, socket) do
|
||||||
|
{:noreply, push_patch(socket, to: "/search?q=#{query}")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(_event, _params, _socket), do: :cont
|
||||||
|
end
|
||||||
@ -1,34 +0,0 @@
|
|||||||
defmodule BerrypodWeb.Shop.Search do
|
|
||||||
use BerrypodWeb, :live_view
|
|
||||||
|
|
||||||
alias Berrypod.{Pages, Search}
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
page = Pages.get_page("search")
|
|
||||||
{:ok, socket |> assign(:page_title, "Search") |> assign(:page, page)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_params(params, _uri, socket) do
|
|
||||||
query = params["q"] || ""
|
|
||||||
results = if query != "", do: Search.search(query), else: []
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:search_page_query, query)
|
|
||||||
|> assign(:search_page_results, results)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("search_submit", %{"q" => query}, socket) do
|
|
||||||
{:noreply, push_patch(socket, to: ~p"/search?q=#{query}")}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -31,6 +31,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
# Page editing state
|
# Page editing state
|
||||||
|> assign(:editing, false)
|
|> assign(:editing, false)
|
||||||
|> assign(:editing_blocks, nil)
|
|> assign(:editing_blocks, nil)
|
||||||
|
|> assign(:editor_page_slug, nil)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|> assign(:editor_at_defaults, true)
|
|> assign(:editor_at_defaults, true)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
@ -67,13 +68,81 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── handle_params: track current path ────────────────────────────
|
# ── handle_params: track current path and restore editor state ────
|
||||||
|
|
||||||
defp handle_editor_params(_params, uri, socket) do
|
defp handle_editor_params(params, uri, socket) do
|
||||||
parsed = URI.parse(uri)
|
parsed = URI.parse(uri)
|
||||||
|
|
||||||
# Store the current path for reference (e.g. the Done button)
|
socket =
|
||||||
{:cont, assign(socket, :editor_current_path, parsed.path)}
|
socket
|
||||||
|
|> assign(:editor_current_path, parsed.path)
|
||||||
|
|> maybe_restore_editor_state(params)
|
||||||
|
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Restore editor state from URL params on navigation
|
||||||
|
# Only activates state if not already in the requested state (avoids loops)
|
||||||
|
defp maybe_restore_editor_state(socket, params) do
|
||||||
|
if socket.assigns.is_admin do
|
||||||
|
requested_tab = params["edit"]
|
||||||
|
current_tab = socket.assigns.editor_active_tab
|
||||||
|
current_state = socket.assigns.editor_sheet_state
|
||||||
|
|
||||||
|
# If already in the correct state, don't re-apply
|
||||||
|
already_correct? =
|
||||||
|
current_state == :open && requested_tab &&
|
||||||
|
String.to_existing_atom(requested_tab) == current_tab
|
||||||
|
|
||||||
|
if already_correct? do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
case requested_tab do
|
||||||
|
"theme" ->
|
||||||
|
socket
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
|> assign(:editor_active_tab, :theme)
|
||||||
|
|> maybe_enter_theme_mode()
|
||||||
|
|
||||||
|
"page" ->
|
||||||
|
socket
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
|> assign(:editor_active_tab, :page)
|
||||||
|
|> maybe_enter_page_mode()
|
||||||
|
|
||||||
|
"settings" ->
|
||||||
|
socket
|
||||||
|
|> assign(:editor_sheet_state, :open)
|
||||||
|
|> assign(:editor_active_tab, :settings)
|
||||||
|
|> maybe_enter_theme_mode()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_enter_theme_mode(socket) do
|
||||||
|
if socket.assigns.theme_editing do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
load_theme_state(socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_enter_page_mode(socket) do
|
||||||
|
if socket.assigns.editing do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
if socket.assigns[:page] do
|
||||||
|
enter_edit_mode(socket)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── handle_info ─────────────────────────────────────────────────
|
# ── handle_info ─────────────────────────────────────────────────
|
||||||
@ -703,6 +772,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
socket
|
socket
|
||||||
|> assign(:editing, true)
|
|> assign(:editing, true)
|
||||||
|> assign(:editing_blocks, page.blocks)
|
|> assign(:editing_blocks, page.blocks)
|
||||||
|
|> assign(:editor_page_slug, page.slug)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|> assign(:editor_at_defaults, at_defaults)
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
@ -725,6 +795,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
socket
|
socket
|
||||||
|> assign(:editing, false)
|
|> assign(:editing, false)
|
||||||
|> assign(:editing_blocks, nil)
|
|> assign(:editing_blocks, nil)
|
||||||
|
|> assign(:editor_page_slug, nil)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
|> assign(:editor_future, [])
|
|> assign(:editor_future, [])
|
||||||
|
|||||||
@ -178,7 +178,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Theme editor content - shows theme controls
|
# Theme editor content - uses shared component
|
||||||
attr :theme_editor_settings, :map, default: nil
|
attr :theme_editor_settings, :map, default: nil
|
||||||
attr :theme_editor_active_preset, :atom, default: nil
|
attr :theme_editor_active_preset, :atom, default: nil
|
||||||
attr :theme_editor_presets, :list, default: []
|
attr :theme_editor_presets, :list, default: []
|
||||||
@ -187,131 +187,14 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
|
|
||||||
defp theme_editor_content(assigns) do
|
defp theme_editor_content(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="editor-theme-content">
|
<.compact_editor
|
||||||
<%= if @theme_editor_settings do %>
|
theme_settings={@theme_editor_settings}
|
||||||
<%!-- Shop name --%>
|
active_preset={@theme_editor_active_preset}
|
||||||
<div class="theme-section">
|
presets={@theme_editor_presets}
|
||||||
<label class="theme-section-label">Shop name</label>
|
site_name={@site_name}
|
||||||
<form phx-change="theme_update_setting" phx-value-field="site_name">
|
customise_open={@theme_editor_customise_open}
|
||||||
<input
|
event_prefix="theme_"
|
||||||
type="text"
|
|
||||||
name="site_name"
|
|
||||||
value={@site_name}
|
|
||||||
placeholder="Your shop name"
|
|
||||||
class="admin-input"
|
|
||||||
/>
|
/>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Presets --%>
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Preset</label>
|
|
||||||
<div class="theme-presets">
|
|
||||||
<%= for {preset_name, description} <- @theme_editor_presets do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="theme_apply_preset"
|
|
||||||
phx-value-preset={preset_name}
|
|
||||||
class={[
|
|
||||||
"theme-preset",
|
|
||||||
@theme_editor_active_preset == preset_name && "theme-preset-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div class="theme-preset-name">{preset_name}</div>
|
|
||||||
<div class="theme-preset-desc">{description}</div>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Mood --%>
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Colour mood</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="theme_update_setting"
|
|
||||||
phx-value-field="mood"
|
|
||||||
phx-value-setting_value={mood}
|
|
||||||
class={["theme-chip", @theme_editor_settings.mood == mood && "theme-chip-active"]}
|
|
||||||
>
|
|
||||||
{mood}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Typography --%>
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Font style</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="theme_update_setting"
|
|
||||||
phx-value-field="typography"
|
|
||||||
phx-value-setting_value={typo}
|
|
||||||
class={[
|
|
||||||
"theme-chip",
|
|
||||||
@theme_editor_settings.typography == typo && "theme-chip-active"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{typo}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Shape --%>
|
|
||||||
<div class="theme-section">
|
|
||||||
<label class="theme-section-label">Corner style</label>
|
|
||||||
<div class="theme-chips">
|
|
||||||
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="theme_update_setting"
|
|
||||||
phx-value-field="shape"
|
|
||||||
phx-value-setting_value={shape}
|
|
||||||
class={["theme-chip", @theme_editor_settings.shape == shape && "theme-chip-active"]}
|
|
||||||
>
|
|
||||||
{shape}
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- More options link --%>
|
|
||||||
<details
|
|
||||||
class="theme-customise"
|
|
||||||
id="theme-customise-section"
|
|
||||||
open={@theme_editor_customise_open}
|
|
||||||
>
|
|
||||||
<summary class="theme-customise-summary" phx-click="theme_toggle_customise">
|
|
||||||
<span class="theme-customise-label">More options</span>
|
|
||||||
<svg
|
|
||||||
class="theme-customise-chevron"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</summary>
|
|
||||||
<div class="theme-customise-body">
|
|
||||||
<p class="admin-text-secondary">
|
|
||||||
For full theme customisation including branding, colours, and layout, <a
|
|
||||||
href="/admin/theme"
|
|
||||||
class="admin-link"
|
|
||||||
>visit the theme editor</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<% else %>
|
|
||||||
<p class="admin-text-secondary">Loading theme settings...</p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -727,7 +610,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<ul class="collection-filter-pills">
|
<ul class="collection-filter-pills">
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate={collection_path("all", @current_sort)}
|
patch={collection_path("all", @current_sort)}
|
||||||
aria-current={@current_slug == nil && "page"}
|
aria-current={@current_slug == nil && "page"}
|
||||||
class={["collection-filter-pill", @current_slug == nil && "active"]}
|
class={["collection-filter-pill", @current_slug == nil && "active"]}
|
||||||
>
|
>
|
||||||
@ -736,7 +619,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate={collection_path("sale", @current_sort)}
|
patch={collection_path("sale", @current_sort)}
|
||||||
aria-current={@current_slug == "sale" && "page"}
|
aria-current={@current_slug == "sale" && "page"}
|
||||||
class={["collection-filter-pill", @current_slug == "sale" && "active"]}
|
class={["collection-filter-pill", @current_slug == "sale" && "active"]}
|
||||||
>
|
>
|
||||||
@ -746,7 +629,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<%= for category <- assigns[:categories] || [] do %>
|
<%= for category <- assigns[:categories] || [] do %>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate={collection_path(category.slug, @current_sort)}
|
patch={collection_path(category.slug, @current_sort)}
|
||||||
aria-current={@current_slug == category.slug && "page"}
|
aria-current={@current_slug == category.slug && "page"}
|
||||||
class={["collection-filter-pill", @current_slug == category.slug && "active"]}
|
class={["collection-filter-pill", @current_slug == category.slug && "active"]}
|
||||||
>
|
>
|
||||||
@ -813,7 +696,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<%= if (assigns[:products] || []) == [] do %>
|
<%= if (assigns[:products] || []) == [] do %>
|
||||||
<div class="collection-empty">
|
<div class="collection-empty">
|
||||||
<p>No products found in this collection.</p>
|
<p>No products found in this collection.</p>
|
||||||
<.link navigate={~p"/collections/all"} class="collection-empty-link">
|
<.link patch={~p"/collections/all"} class="collection-empty-link">
|
||||||
View all products
|
View all products
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
@ -1020,7 +903,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
Please wait while we confirm your payment. This usually takes a few seconds.
|
Please wait while we confirm your payment. This usually takes a few seconds.
|
||||||
</p>
|
</p>
|
||||||
<p class="checkout-pending-hint">
|
<p class="checkout-pending-hint">
|
||||||
If this page doesn't update, please <.link navigate="/contact" class="checkout-contact-link">contact us</.link>.
|
If this page doesn't update, please <.link patch="/contact" class="checkout-contact-link">contact us</.link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -1045,20 +928,20 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<div class="orders-empty">
|
<div class="orders-empty">
|
||||||
<p>This link has expired or is invalid.</p>
|
<p>This link has expired or is invalid.</p>
|
||||||
<p class="orders-empty-hint">
|
<p class="orders-empty-hint">
|
||||||
Head back to the <.link navigate="/contact">contact page</.link> to request a new one.
|
Head back to the <.link patch="/contact">contact page</.link> to request a new one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% assigns[:orders] == [] -> %>
|
<% assigns[:orders] == [] -> %>
|
||||||
<div class="orders-empty">
|
<div class="orders-empty">
|
||||||
<p>No orders found for that email address.</p>
|
<p>No orders found for that email address.</p>
|
||||||
<p class="orders-empty-hint">
|
<p class="orders-empty-hint">
|
||||||
If something doesn't look right, <.link navigate="/contact">get in touch</.link>.
|
If something doesn't look right, <.link patch="/contact">get in touch</.link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% true -> %>
|
<% true -> %>
|
||||||
<div class="orders-list">
|
<div class="orders-list">
|
||||||
<%= for order <- assigns[:orders] do %>
|
<%= for order <- assigns[:orders] do %>
|
||||||
<.link navigate={"/orders/#{order.order_number}"} class="order-summary-card">
|
<.link patch={"/orders/#{order.order_number}"} class="order-summary-card">
|
||||||
<div class="order-summary-top">
|
<div class="order-summary-top">
|
||||||
<div>
|
<div>
|
||||||
<p class="order-summary-number">{order.order_number}</p>
|
<p class="order-summary-number">{order.order_number}</p>
|
||||||
@ -1100,7 +983,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
~H"""
|
~H"""
|
||||||
<%= if assigns[:order] do %>
|
<%= if assigns[:order] do %>
|
||||||
<div class="order-detail-header">
|
<div class="order-detail-header">
|
||||||
<.link navigate="/orders" class="order-detail-back">← Back to orders</.link>
|
<.link patch="/orders" class="order-detail-back">← Back to orders</.link>
|
||||||
<h1 class="checkout-heading" style="margin-top: 1.5rem;">{assigns[:order].order_number}</h1>
|
<h1 class="checkout-heading" style="margin-top: 1.5rem;">{assigns[:order].order_number}</h1>
|
||||||
<p class="checkout-meta">{Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}</p>
|
<p class="checkout-meta">{Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}</p>
|
||||||
<span class={"order-status-badge order-status-badge-#{assigns[:order].fulfilment_status} order-status-badge-lg"}>
|
<span class={"order-status-badge order-status-badge-#{assigns[:order].fulfilment_status} order-status-badge-lg"}>
|
||||||
@ -1155,7 +1038,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<div>
|
<div>
|
||||||
<%= if info && info.slug do %>
|
<%= if info && info.slug do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{info.slug}"}
|
patch={"/products/#{info.slug}"}
|
||||||
class="checkout-item-name checkout-item-link"
|
class="checkout-item-name checkout-item-link"
|
||||||
>
|
>
|
||||||
{item.product_name}
|
{item.product_name}
|
||||||
@ -1245,7 +1128,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<%= if (assigns[:search_page_query] || "") != "" do %>
|
<%= if (assigns[:search_page_query] || "") != "" do %>
|
||||||
<div class="collection-empty">
|
<div class="collection-empty">
|
||||||
<p>No products found for “{assigns[:search_page_query]}”</p>
|
<p>No products found for “{assigns[:search_page_query]}”</p>
|
||||||
<.link navigate="/collections/all" class="collection-empty-link">Browse all products</.link>
|
<.link patch="/collections/all" class="collection-empty-link">Browse all products</.link>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -1285,7 +1168,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
~H"""
|
~H"""
|
||||||
<div class="block-button" data-align={@alignment}>
|
<div class="block-button" data-align={@alignment}>
|
||||||
<.link
|
<.link
|
||||||
navigate={@href}
|
patch={@href}
|
||||||
class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"}
|
class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"}
|
||||||
>
|
>
|
||||||
{@text}
|
{@text}
|
||||||
|
|||||||
@ -279,22 +279,22 @@ defmodule BerrypodWeb.Router do
|
|||||||
{BerrypodWeb.PageEditorHook, :mount_page_editor},
|
{BerrypodWeb.PageEditorHook, :mount_page_editor},
|
||||||
{BerrypodWeb.NewsletterHook, :mount_newsletter}
|
{BerrypodWeb.NewsletterHook, :mount_newsletter}
|
||||||
] do
|
] do
|
||||||
live "/", Shop.Home, :index
|
live "/", Shop.Page, :home
|
||||||
live "/about", Shop.Content, :about
|
live "/about", Shop.Page, :about
|
||||||
live "/delivery", Shop.Content, :delivery
|
live "/delivery", Shop.Page, :delivery
|
||||||
live "/privacy", Shop.Content, :privacy
|
live "/privacy", Shop.Page, :privacy
|
||||||
live "/terms", Shop.Content, :terms
|
live "/terms", Shop.Page, :terms
|
||||||
live "/contact", Shop.Contact, :index
|
live "/contact", Shop.Page, :contact
|
||||||
live "/collections/:slug", Shop.Collection, :show
|
live "/collections/:slug", Shop.Page, :collection
|
||||||
live "/products/:id", Shop.ProductShow, :show
|
live "/products/:id", Shop.Page, :product
|
||||||
live "/cart", Shop.Cart, :index
|
live "/cart", Shop.Page, :cart
|
||||||
live "/search", Shop.Search, :index
|
live "/search", Shop.Page, :search
|
||||||
live "/checkout/success", Shop.CheckoutSuccess, :show
|
live "/checkout/success", Shop.Page, :checkout_success
|
||||||
live "/orders", Shop.Orders, :index
|
live "/orders", Shop.Page, :orders
|
||||||
live "/orders/:order_number", Shop.OrderDetail, :show
|
live "/orders/:order_number", Shop.Page, :order_detail
|
||||||
|
|
||||||
# Catch-all for custom CMS pages — must be last
|
# Catch-all for custom CMS pages — must be last
|
||||||
live "/:slug", Shop.CustomPage, :show
|
live "/:slug", Shop.Page, :custom_page
|
||||||
end
|
end
|
||||||
|
|
||||||
# Checkout (POST — creates Stripe session and redirects)
|
# Checkout (POST — creates Stripe session and redirects)
|
||||||
|
|||||||
@ -86,13 +86,13 @@ defmodule BerrypodWeb.Shop.SearchIntegrationTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "search results rendering" do
|
describe "search results rendering" do
|
||||||
test "result links use navigate for LiveView navigation", %{conn: conn, mountain: mountain} do
|
test "result links use patch for LiveView navigation", %{conn: conn, mountain: mountain} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/")
|
{:ok, view, _html} = live(conn, ~p"/")
|
||||||
|
|
||||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||||
|
|
||||||
assert html =~ ~s(href="/products/#{mountain.slug}")
|
assert html =~ ~s(href="/products/#{mountain.slug}")
|
||||||
assert html =~ ~s(data-phx-link="redirect")
|
assert html =~ ~s(data-phx-link="patch")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "results have ARIA listbox and option roles", %{conn: conn} do
|
test "results have ARIA listbox and option roles", %{conn: conn} do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user