defmodule BerrypodWeb.ShopComponents.Layout do use Phoenix.Component import BerrypodWeb.ShopComponents.Cart import BerrypodWeb.ShopComponents.Content @doc """ Renders the announcement bar. The bar displays promotional messaging at the top of the page. It uses CSS custom properties for theming. ## Attributes * `theme_settings` - Required. The theme settings map. * `message` - Optional. The announcement message to display. Defaults to "Free delivery on orders over £40". ## Examples <.announcement_bar theme_settings={@theme_settings} /> <.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" /> """ attr :theme_settings, :map, required: true attr :message, :string, default: "Sample announcement – e.g. free delivery, sales, or new drops" def announcement_bar(assigns) do ~H"""

{@message}

""" end @doc """ Renders the skip link for keyboard navigation accessibility. This is a standard accessibility pattern that allows keyboard users to skip directly to the main content. """ def skip_link(assigns) do ~H""" """ end # Keys accepted by shop_layout — used by layout_assigns/1 so page templates # can spread assigns without listing each one explicitly. @layout_keys ~w(theme_settings generated_css site_name logo_image header_image mode cart_items cart_count cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin search_query search_results search_open categories shipping_estimate country_code available_countries editing theme_editing editor_current_path editor_sidebar_open editor_active_tab editor_sheet_state editor_dirty editor_save_status header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a @doc """ Extracts the assigns relevant to `shop_layout` from a full assigns map. Page templates can use this instead of listing every attr explicitly: <.shop_layout {layout_assigns(assigns)} active_page="home"> ... """ def layout_assigns(assigns) do Map.take(assigns, @layout_keys) end @doc """ Wraps page content in the standard shop shell: container, header, footer, cart drawer, search modal, and mobile bottom nav. Templates pass their unique `
` content as the inner block. The `error_page` flag disables the CartPersist hook and mobile bottom nav. """ attr :theme_settings, :map, required: true attr :site_name, :string, required: true attr :logo_image, :any, required: true attr :header_image, :any, required: true attr :mode, :atom, required: true attr :cart_items, :list, required: true attr :cart_count, :integer, required: true attr :cart_subtotal, :string, required: true attr :cart_total, :string, default: nil attr :cart_drawer_open, :boolean, default: false attr :cart_status, :string, default: nil attr :active_page, :string, required: true attr :error_page, :boolean, default: false attr :is_admin, :boolean, default: false attr :editing, :boolean, default: false attr :editor_current_path, :string, default: nil attr :editor_sidebar_open, :boolean, default: true attr :search_query, :string, default: "" attr :search_results, :list, default: [] attr :search_open, :boolean, default: false attr :shipping_estimate, :integer, default: nil attr :country_code, :string, default: "GB" attr :available_countries, :list, default: [] attr :header_nav_items, :list, default: [] attr :footer_nav_items, :list, default: [] attr :newsletter_enabled, :boolean, default: false attr :newsletter_state, :atom, default: :idle attr :stripe_connected, :boolean, default: true attr :generated_css, :string, default: nil slot :inner_block, required: true def shop_layout(assigns) do ~H"""
<%!-- Live-updatable theme CSS (overrides static version in head) --%> <%= if @generated_css do %> {Phoenix.HTML.raw("")} <% end %> <.skip_link /> <%= if @theme_settings.announcement_bar do %> <.announcement_bar theme_settings={@theme_settings} /> <% end %> <.shop_header theme_settings={@theme_settings} site_name={@site_name} logo_image={@logo_image} header_image={@header_image} active_page={@active_page} mode={@mode} cart_count={@cart_count} is_admin={@is_admin} header_nav_items={@header_nav_items} /> {render_slot(@inner_block)} <.shop_footer theme_settings={@theme_settings} site_name={@site_name} mode={@mode} categories={assigns[:categories] || []} footer_nav_items={@footer_nav_items} newsletter_enabled={@newsletter_enabled} newsletter_state={@newsletter_state} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} total={@cart_total} cart_count={@cart_count} mode={@mode} open={@cart_drawer_open} cart_status={@cart_status} shipping_estimate={@shipping_estimate} country_code={@country_code} available_countries={@available_countries} stripe_connected={@stripe_connected} /> <.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} search_query={@search_query} search_results={@search_results} search_open={@search_open} /> <.mobile_nav_drawer :if={!@error_page} active_page={@active_page} mode={@mode} items={@header_nav_items} categories={assigns[:categories] || []} />
""" end @doc """ Renders a mobile bottom navigation bar. This component provides thumb-friendly navigation for mobile devices, following modern UX best practices. It's hidden on larger screens where the standard header navigation is used. ## Attributes * `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact"). * `mode` - Optional. Either `:live` (default) for real navigation or `:preview` for theme preview mode with phx-click handlers. * `cart_count` - Optional. Number of items in cart for badge display. Default: 0. ## Examples <.mobile_bottom_nav active_page="home" /> <.mobile_bottom_nav active_page="collection" mode={:preview} /> """ attr :active_page, :string, required: true attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 attr :items, :list, default: [] def mobile_bottom_nav(assigns) do ~H""" """ end attr :icon, :atom, required: true attr :label, :string, required: true attr :page, :string, required: true attr :href, :string, required: true attr :active_page, :string, required: true attr :active_pages, :list, default: nil attr :mode, :atom, default: :live defp mobile_nav_item(assigns) do active_pages = assigns.active_pages || [assigns.page] is_current = assigns.active_page in active_pages assigns = assign(assigns, :is_current, is_current) ~H"""
  • <%= if @mode == :preview do %> <.nav_icon icon={@icon} /> {@label} <% else %> <.link patch={@href} class="mobile-nav-link" aria-current={if @is_current, do: "page", else: nil} > <.nav_icon icon={@icon} /> {@label} <% end %>
  • """ end defp mobile_icon("home"), do: :home defp mobile_icon("collection"), do: :shop defp mobile_icon("about"), do: :about defp mobile_icon("contact"), do: :contact defp mobile_icon(_), do: :page defp nav_icon(%{icon: :home} = assigns) do ~H""" """ end defp nav_icon(%{icon: :shop} = assigns) do ~H""" """ end defp nav_icon(%{icon: :about} = assigns) do ~H""" """ end defp nav_icon(%{icon: :contact} = assigns) do ~H""" """ end defp nav_icon(%{icon: :page} = assigns) do ~H""" """ end @doc """ Renders the search modal overlay with live search results. ## Attributes * `hint_text` - Hint text shown when no query is entered. * `search_query` - Current search query string. * `search_results` - List of Product structs matching the query. """ attr :hint_text, :string, default: nil attr :search_query, :string, default: "" attr :search_results, :list, default: [] attr :search_open, :boolean, default: false def search_modal(assigns) do alias Berrypod.Cart alias Berrypod.Products.{Product, ProductImage} assigns = assign( assigns, :results_with_images, assigns.search_results |> Enum.with_index() |> Enum.map(fn {product, idx} -> image = Product.primary_image(product) %{product: product, image_url: ProductImage.url(image, 400), idx: idx} end) ) ~H"""
    <%= cond do %> <% @search_results != [] -> %>
    • <.link patch={"/products/#{item.product.slug || item.product.id}"} class="search-result" phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")} >
      {item.product.title}

      {item.product.title}

      {item.product.category} {Cart.format_price(item.product.cheapest_price)}

    <% String.length(@search_query) >= 2 -> %>

    No products found for "{@search_query}"

    <% @hint_text != nil -> %>

    {@hint_text}

    <% true -> %> <% end %>
    """ end @doc """ Renders the mobile navigation drawer. A slide-out drawer containing the main navigation links for mobile users. Triggered by the hamburger menu button in the header. """ attr :active_page, :string, required: true attr :mode, :atom, default: :live attr :items, :list, default: [] attr :categories, :list, default: [] def mobile_nav_drawer(assigns) do ~H"""
    """ end @doc """ Renders the shop footer with newsletter signup and links. ## Attributes * `theme_settings` - Required. The theme settings map. * `mode` - Optional. Either `:live` (default) for real navigation or `:preview` for theme preview mode with phx-click handlers. ## Examples <.shop_footer theme_settings={@theme_settings} /> <.shop_footer theme_settings={@theme_settings} mode={:preview} /> """ attr :theme_settings, :map, required: true attr :site_name, :string, required: true attr :mode, :atom, default: :live attr :categories, :list, default: [] attr :footer_nav_items, :list, default: [] attr :newsletter_enabled, :boolean, default: false attr :newsletter_state, :atom, default: :idle def shop_footer(assigns) do assigns = assign(assigns, :current_year, Date.utc_today().year) ~H""" """ end @doc """ Renders the shop header with logo, navigation, and actions. ## Attributes * `theme_settings` - Required. The theme settings map. * `logo_image` - Optional. The logo image struct (with id, is_svg fields). * `header_image` - Optional. The header background image struct. * `active_page` - Optional. Current page for nav highlighting. * `mode` - Optional. Either `:live` (default) or `:preview`. * `cart_count` - Optional. Number of items in cart. Defaults to 0. ## Examples <.shop_header theme_settings={@theme_settings} /> <.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} /> """ attr :theme_settings, :map, required: true attr :site_name, :string, required: true attr :logo_image, :map, default: nil attr :header_image, :map, default: nil attr :active_page, :string, default: nil attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 attr :is_admin, :boolean, default: false attr :header_nav_items, :list, default: [] def shop_header(assigns) do ~H"""
    <%= if @theme_settings.header_background_enabled && @header_image do %>
    <% end %> <%!-- Hamburger menu button (mobile only) --%>
    <%!-- Admin cog: always visible for admins, links to admin dashboard --%> <.link :if={@is_admin} href="/admin" class="header-icon-btn" aria-label="Admin dashboard" > <.admin_cog_svg /> <%= if @cart_count > 0 do %> {@cart_count} <% end %> Cart ({@cart_count})
    """ end defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do clean_color = String.trim_leading(color, "#") "/images/#{logo_image.id}/recolored/#{clean_color}" end defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp" # Logo content that links to home, except when already on home page. # This follows accessibility best practices - current page should not be a link. attr :theme_settings, :map, required: true attr :site_name, :string, required: true attr :logo_image, :map, default: nil attr :active_page, :string, default: nil attr :mode, :atom, default: :live defp logo_content(assigns) do is_home = assigns.active_page == "home" assigns = assign(assigns, :is_home, is_home) ~H""" <%= if @is_home do %> <.logo_inner theme_settings={@theme_settings} site_name={@site_name} logo_image={@logo_image} /> <% else %> <%= if @mode == :preview do %> <.logo_inner theme_settings={@theme_settings} site_name={@site_name} logo_image={@logo_image} /> <% else %> <.link patch="/" class="shop-logo-link"> <.logo_inner theme_settings={@theme_settings} site_name={@site_name} logo_image={@logo_image} /> <% end %> <% end %> """ end attr :theme_settings, :map, required: true attr :site_name, :string, required: true attr :logo_image, :map, default: nil defp logo_inner(assigns) do # Show logo if enabled and image exists show_logo = assigns.theme_settings.show_logo && assigns.logo_image # Show site name if enabled, or as fallback when logo should show but image is missing show_site_name = assigns.theme_settings.show_site_name || (assigns.theme_settings.show_logo && !assigns.logo_image) assigns = assigns |> assign(:show_logo, show_logo) |> assign(:show_site_name, show_site_name) ~H""" <%= if @show_logo do %> {@site_name} <% end %> <%= if @show_site_name do %> {@site_name} <% end %> """ end defp header_background_style(settings, header_image) do "position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <> "background-image: url('/image_cache/#{header_image.id}.webp'); " <> "background-size: #{settings.header_zoom}%; " <> "background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <> "background-repeat: no-repeat; z-index: 0;" end # Navigation item that renders as a span (not a link) when on the current page. # This follows accessibility best practices - current page should not be a link. attr :label, :string, required: true attr :page, :string, required: true attr :active_page, :string, required: true attr :href, :string, default: nil attr :mode, :atom, default: :live attr :active_pages, :list, default: nil defp nav_item(assigns) do # Allow matching multiple pages (e.g., "Shop" is active for both collection and pdp) active_pages = assigns.active_pages || [assigns.page] is_current = assigns.active_page in active_pages assigns = assign(assigns, :is_current, is_current) ~H""" <%= if @is_current do %> {@label} <% else %> <%= if @mode == :preview do %> {@label} <% else %> <.link patch={@href} class="nav-link"> {@label} <% end %> <% end %> """ end defp edit_pencil_svg(assigns) do ~H""" """ end defp open_cart_drawer_js do Phoenix.LiveView.JS.push("open_cart_drawer") end # ── Editor sheet ──────────────────────────────────────────────────── @doc """ Renders the unified editor sheet for page/theme/settings editing. The sheet is anchored to the bottom edge on mobile (<768px) and the right edge on desktop (≥768px). It has three states on mobile (collapsed, partial, full) and two states on desktop (collapsed, open). ## Attributes * `editing` - Whether page edit mode is active. * `theme_editing` - Whether theme edit mode is active. * `editor_dirty` - Whether there are unsaved page changes. * `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open). * `editor_active_tab` - Current tab (:page, :theme, :settings). * `has_editable_page` - Whether the current page has editable blocks. ## Slots * `inner_block` - The editor content (block list, settings, etc.). """ attr :editing, :boolean, default: false attr :theme_editing, :boolean, default: false attr :editor_dirty, :boolean, default: false attr :editor_sheet_state, :atom, default: :collapsed attr :editor_save_status, :atom, default: :idle attr :editor_active_tab, :atom, default: :page attr :has_editable_page, :boolean, default: false slot :inner_block def editor_sheet(assigns) do # Determine panel title based on active tab title = case assigns.editor_active_tab do :page -> "Page" :theme -> "Theme" :settings -> "Settings" end # Any editing mode active any_editing = assigns.editing || assigns.theme_editing assigns = assigns |> assign(:title, title) |> assign(:any_editing, any_editing) ~H""" <%!-- Floating action button: always visible when panel is closed --%> <%!-- Overlay to catch taps outside the panel --%>