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` - The announcement message to display. * `link` - Optional URL to link the announcement to. * `style` - Visual style: "info", "sale", or "warning". ## Examples <.announcement_bar theme_settings={@theme_settings} message="Free shipping!" /> <.announcement_bar theme_settings={@theme_settings} message="20% off!" link="/sale" style="sale" /> """ attr :theme_settings, :map, required: true attr :message, :string, default: "" attr :link, :string, default: "" attr :style, :string, default: "info" def announcement_bar(assigns) do # Use default message if none provided message = if assigns.message in ["", nil] do "Sample announcement – e.g. free delivery, sales, or new drops" else assigns.message end assigns = assign(assigns, :display_message, message) ~H"""
<%= if @link != "" do %>

{@display_message}

<% else %>

{@display_message}

<% end %>
""" 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 social_links announcement_text announcement_link announcement_style 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 base = Map.take(assigns, @layout_keys) # When site editor is active, use in-memory values for live preview # The site_* assigns are the editor's working copies, while announcement_* # and social_links are the database-loaded values from theme_hook # Only override when site_editing is true (editor has loaded site state) if assigns[:site_editing] do # Convert raw SocialLink structs to shop format social_links = format_social_links_for_shop(assigns[:site_social_links] || []) base |> Map.put(:announcement_text, assigns[:site_announcement_text]) |> Map.put(:announcement_link, assigns[:site_announcement_link]) |> Map.put(:announcement_style, assigns[:site_announcement_style]) |> Map.put(:social_links, social_links) else base end end # Convert raw SocialLink structs to the format expected by shop components # Filters out links with empty URLs (incomplete entries still being edited) # Using String.to_atom is safe here because platforms are validated by the schema defp format_social_links_for_shop(links) do links |> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end) |> Enum.map(fn link -> platform = if is_binary(link.platform), do: link.platform, else: to_string(link.platform) %{ platform: String.to_atom(platform), url: link.url, label: platform_display_label(platform) } end) end # Social defp platform_display_label("instagram"), do: "Instagram" defp platform_display_label("threads"), do: "Threads" defp platform_display_label("facebook"), do: "Facebook" defp platform_display_label("twitter"), do: "Twitter" defp platform_display_label("snapchat"), do: "Snapchat" defp platform_display_label("linkedin"), do: "LinkedIn" # Video & streaming defp platform_display_label("youtube"), do: "YouTube" defp platform_display_label("twitch"), do: "Twitch" defp platform_display_label("vimeo"), do: "Vimeo" defp platform_display_label("kick"), do: "Kick" defp platform_display_label("rumble"), do: "Rumble" # Music & podcasts defp platform_display_label("spotify"), do: "Spotify" defp platform_display_label("soundcloud"), do: "SoundCloud" defp platform_display_label("bandcamp"), do: "Bandcamp" defp platform_display_label("applepodcasts"), do: "Podcasts" # Creative defp platform_display_label("pinterest"), do: "Pinterest" defp platform_display_label("behance"), do: "Behance" defp platform_display_label("dribbble"), do: "Dribbble" defp platform_display_label("tumblr"), do: "Tumblr" defp platform_display_label("medium"), do: "Medium" # Support & sales defp platform_display_label("patreon"), do: "Patreon" defp platform_display_label("kofi"), do: "Ko-fi" defp platform_display_label("etsy"), do: "Etsy" defp platform_display_label("gumroad"), do: "Gumroad" defp platform_display_label("substack"), do: "Substack" # Federated defp platform_display_label("mastodon"), do: "Mastodon" defp platform_display_label("pixelfed"), do: "Pixelfed" defp platform_display_label("bluesky"), do: "Bluesky" defp platform_display_label("peertube"), do: "PeerTube" defp platform_display_label("lemmy"), do: "Lemmy" defp platform_display_label("matrix"), do: "Matrix" # Developer defp platform_display_label("github"), do: "GitHub" defp platform_display_label("gitlab"), do: "GitLab" defp platform_display_label("codeberg"), do: "Codeberg" defp platform_display_label("sourcehut"), do: "SourceHut" defp platform_display_label("reddit"), do: "Reddit" # Messaging defp platform_display_label("discord"), do: "Discord" defp platform_display_label("telegram"), do: "Telegram" defp platform_display_label("signal"), do: "Signal" defp platform_display_label("whatsapp"), do: "WhatsApp" # Other defp platform_display_label("linktree"), do: "Linktree" defp platform_display_label("rss"), do: "RSS" defp platform_display_label("website"), do: "Website" defp platform_display_label("custom"), do: "Link" defp platform_display_label(other), do: String.capitalize(other) @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 :social_links, :list, default: [] attr :announcement_text, :string, default: "" attr :announcement_link, :string, default: "" attr :announcement_style, :string, default: "info" 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} message={@announcement_text} link={@announcement_link} style={@announcement_style} /> <% 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} social_links={@social_links} 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 :social_links, :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 :theme_dirty, :boolean, default: false attr :site_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 :editor_nav_blocked, :string, default: nil 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" :site -> "Site" :settings -> "Settings" end # Any editing mode active any_editing = assigns.editing || assigns.theme_editing # Any tab has unsaved changes any_dirty = assigns.editor_dirty || assigns.theme_dirty || assigns.site_dirty assigns = assigns |> assign(:title, title) |> assign(:any_editing, any_editing) |> assign(:any_dirty, any_dirty) ~H""" <%!-- Floating action button: always visible when panel is closed --%> <%!-- Overlay to catch taps outside the panel --%>