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 logo_image header_image mode cart_items cart_count cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin search_query search_results search_open categories shipping_estimate country_code available_countries)a @doc """ Extracts the assigns relevant to `shop_layout` from a full assigns map. Page templates can use this instead of listing every attr explicitly: <.shop_layout {layout_assigns(assigns)} active_page="home"> ... """ 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 :logo_image, :any, required: true attr :header_image, :any, required: true attr :mode, :atom, required: true attr :cart_items, :list, required: true attr :cart_count, :integer, required: true attr :cart_subtotal, :string, required: true attr :cart_total, :string, default: nil attr :cart_drawer_open, :boolean, default: false attr :cart_status, :string, default: nil attr :active_page, :string, required: true attr :error_page, :boolean, default: false attr :is_admin, :boolean, default: false attr :search_query, :string, default: "" attr :search_results, :list, default: [] attr :search_open, :boolean, default: false attr :shipping_estimate, :integer, default: nil attr :country_code, :string, default: "GB" attr :available_countries, :list, default: [] slot :inner_block, required: true def shop_layout(assigns) do ~H"""
<.skip_link /> <%= if @theme_settings.announcement_bar do %> <.announcement_bar theme_settings={@theme_settings} /> <% end %> <.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page={@active_page} mode={@mode} cart_count={@cart_count} is_admin={@is_admin} /> {render_slot(@inner_block)} <.shop_footer theme_settings={@theme_settings} mode={@mode} categories={assigns[:categories] || []} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} total={@cart_total} cart_count={@cart_count} mode={@mode} open={@cart_drawer_open} cart_status={@cart_status} shipping_estimate={@shipping_estimate} country_code={@country_code} available_countries={@available_countries} /> <.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} search_query={@search_query} search_results={@search_results} search_open={@search_open} /> <.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
""" end @doc """ Renders a mobile bottom navigation bar. This component provides thumb-friendly navigation for mobile devices, following modern UX best practices. It's hidden on larger screens where the standard header navigation is used. ## Attributes * `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact"). * `mode` - Optional. Either `:live` (default) for real navigation or `:preview` for theme preview mode with phx-click handlers. * `cart_count` - Optional. Number of items in cart for badge display. Default: 0. ## Examples <.mobile_bottom_nav active_page="home" /> <.mobile_bottom_nav active_page="collection" mode={:preview} /> """ attr :active_page, :string, required: true attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 def mobile_bottom_nav(assigns) do ~H""" """ 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 navigate={@href} class="mobile-nav-link" aria-current={if @is_current, do: "page", else: nil} > <.nav_icon icon={@icon} /> {@label} <% end %>
  • """ end 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 @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 navigate={"/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 shop footer with newsletter signup and links. ## Attributes * `theme_settings` - Required. The theme settings map containing site_name. * `mode` - Optional. Either `:live` (default) for real navigation or `:preview` for theme preview mode with phx-click handlers. ## Examples <.shop_footer theme_settings={@theme_settings} /> <.shop_footer theme_settings={@theme_settings} mode={:preview} /> """ attr :theme_settings, :map, required: true attr :mode, :atom, default: :live attr :categories, :list, default: [] def shop_footer(assigns) do assigns = assign(assigns, :current_year, Date.utc_today().year) ~H""" """ end @doc """ Renders the shop header with logo, navigation, and actions. ## Attributes * `theme_settings` - Required. The theme settings map. * `logo_image` - Optional. The logo image struct (with id, is_svg fields). * `header_image` - Optional. The header background image struct. * `active_page` - Optional. Current page for nav highlighting. * `mode` - Optional. Either `:live` (default) or `:preview`. * `cart_count` - Optional. Number of items in cart. Defaults to 0. ## Examples <.shop_header theme_settings={@theme_settings} /> <.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} /> """ attr :theme_settings, :map, required: true attr :logo_image, :map, default: nil attr :header_image, :map, default: nil attr :active_page, :string, default: nil attr :mode, :atom, default: :live attr :cart_count, :integer, default: 0 attr :is_admin, :boolean, default: false def shop_header(assigns) do ~H"""
    <%= if @theme_settings.header_background_enabled && @header_image do %>
    <% end %>
    <.link :if={@is_admin} href="/admin" class="header-icon-btn" aria-label="Admin" > <%= 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 :logo_image, :map, default: nil attr :active_page, :string, default: nil attr :mode, :atom, default: :live defp logo_content(assigns) do is_home = assigns.active_page == "home" assigns = assign(assigns, :is_home, is_home) ~H""" <%= if @is_home do %> <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> <% else %> <%= if @mode == :preview do %> <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> <% else %> <.link navigate="/" class="shop-logo-link"> <.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} /> <% end %> <% end %> """ end attr :theme_settings, :map, required: true attr :logo_image, :map, default: nil defp logo_inner(assigns) do ~H""" <%= case @theme_settings.logo_mode do %> <% "text-only" -> %> {@theme_settings.site_name} <% "logo-text" -> %> <%= if @logo_image do %> {@theme_settings.site_name} <% end %> {@theme_settings.site_name} <% "logo-only" -> %> <%= if @logo_image do %> {@theme_settings.site_name} <% else %> {@theme_settings.site_name} <% end %> <% _ -> %> {@theme_settings.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 navigate={@href} class="nav-link"> {@label} <% end %> <% end %> """ end defp open_cart_drawer_js do Phoenix.LiveView.JS.push("open_cart_drawer") end end