add Site context with social links editor and site-wide settings
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
- Add Site context for managing site-wide content (social links, nav items, announcement bar, footer content) - Add SocialLink schema with URL normalization and platform auto-detection supporting 40+ platforms via host and 25+ via URI scheme - Add NavItem schema for header/footer navigation (editor UI coming next) - Add SiteEditor component with collapsible sections for each content type - Wire social links card block and footer to use database data - Filter empty URLs from display in shop components - Add DetailsPreserver hook to preserve collapsible section state - Add comprehensive tests for Site context and SocialLink functions - Remove unused helper functions from onboarding to fix compiler warnings - Move sync_edit_url_param helper to group handle_editor_event clauses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,21 +13,40 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
## Attributes
|
||||
|
||||
* `theme_settings` - Required. The theme settings map.
|
||||
* `message` - Optional. The announcement message to display.
|
||||
Defaults to "Free delivery on orders over £40".
|
||||
* `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} />
|
||||
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
|
||||
<.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: "Sample announcement – e.g. free delivery, sales, or new drops"
|
||||
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"""
|
||||
<div class="announcement-bar">
|
||||
<p>{@message}</p>
|
||||
<div class={"announcement-bar announcement-bar--#{@style}"}>
|
||||
<%= if @link != "" do %>
|
||||
<a href={@link} class="announcement-bar-link">
|
||||
<p>{@display_message}</p>
|
||||
</a>
|
||||
<% else %>
|
||||
<p>{@display_message}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -53,7 +72,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
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
|
||||
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.
|
||||
@@ -65,9 +85,106 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
</.shop_layout>
|
||||
"""
|
||||
def layout_assigns(assigns) do
|
||||
Map.take(assigns, @layout_keys)
|
||||
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.
|
||||
@@ -100,6 +217,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
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
|
||||
@@ -133,7 +254,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<.skip_link />
|
||||
|
||||
<%= if @theme_settings.announcement_bar do %>
|
||||
<.announcement_bar theme_settings={@theme_settings} />
|
||||
<.announcement_bar
|
||||
theme_settings={@theme_settings}
|
||||
message={@announcement_text}
|
||||
link={@announcement_link}
|
||||
style={@announcement_style}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<.shop_header
|
||||
@@ -156,6 +282,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
mode={@mode}
|
||||
categories={assigns[:categories] || []}
|
||||
footer_nav_items={@footer_nav_items}
|
||||
social_links={@social_links}
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
newsletter_state={@newsletter_state}
|
||||
/>
|
||||
@@ -652,6 +779,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
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
|
||||
|
||||
@@ -750,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<p class="footer-copyright">
|
||||
© {@current_year} {@site_name}
|
||||
</p>
|
||||
<.social_links />
|
||||
<.social_links links={@social_links} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1071,9 +1199,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
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
|
||||
@@ -1084,16 +1215,21 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
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 --%>
|
||||
@@ -1110,7 +1246,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
>
|
||||
<.edit_pencil_svg />
|
||||
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
|
||||
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
|
||||
<span :if={@any_editing && @any_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
|
||||
</button>
|
||||
|
||||
<%!-- Overlay to catch taps outside the panel --%>
|
||||
@@ -1131,6 +1267,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
|
||||
data-state={@editor_sheet_state}
|
||||
data-editing={to_string(@any_editing)}
|
||||
data-dirty={to_string(@any_dirty)}
|
||||
phx-hook="EditorSheet"
|
||||
>
|
||||
<%!-- Drag handle for mobile resizing --%>
|
||||
@@ -1141,14 +1278,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<div class="editor-panel-header">
|
||||
<div class="editor-panel-header-left">
|
||||
<span class="editor-panel-title">{@title}</span>
|
||||
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
|
||||
<span :if={@any_dirty} class="editor-panel-dirty" aria-live="polite">
|
||||
<span class="editor-panel-dirty-dot" aria-hidden="true" />
|
||||
<span>Unsaved</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-panel-header-actions">
|
||||
<button
|
||||
:if={@editor_active_tab == :page && @editor_save_status == :saved}
|
||||
:if={@editor_save_status == :saved}
|
||||
type="button"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
disabled
|
||||
@@ -1156,11 +1293,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
Saved ✓
|
||||
</button>
|
||||
<button
|
||||
:if={@editor_active_tab == :page && @editor_save_status != :saved}
|
||||
:if={@editor_save_status != :saved}
|
||||
type="button"
|
||||
phx-click="editor_save"
|
||||
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
|
||||
disabled={!@editor_dirty}
|
||||
phx-click="editor_save_all"
|
||||
class={["admin-btn admin-btn-sm", @any_dirty && "admin-btn-primary"]}
|
||||
disabled={!@any_dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -1201,6 +1338,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
}
|
||||
>
|
||||
Page
|
||||
<span
|
||||
:if={@editor_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1211,16 +1353,26 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
aria-selected={to_string(@editor_active_tab == :theme)}
|
||||
>
|
||||
Theme
|
||||
<span
|
||||
:if={@theme_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
phx-click="editor_set_tab"
|
||||
phx-value-tab="settings"
|
||||
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :settings)}
|
||||
phx-value-tab="site"
|
||||
class={["editor-tab", @editor_active_tab == :site && "editor-tab-active"]}
|
||||
aria-selected={to_string(@editor_active_tab == :site)}
|
||||
>
|
||||
Settings
|
||||
Site
|
||||
<span
|
||||
:if={@site_dirty}
|
||||
class="editor-tab-dirty-dot"
|
||||
aria-label="unsaved changes"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1231,6 +1383,37 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
|
||||
<%!-- Live region for screen reader announcements --%>
|
||||
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
|
||||
|
||||
<%!-- Navigation warning modal --%>
|
||||
<dialog :if={@editor_nav_blocked} class="editor-nav-modal" open>
|
||||
<div class="editor-nav-modal-content">
|
||||
<h3>Unsaved changes</h3>
|
||||
<p>You have unsaved changes that will be lost if you leave.</p>
|
||||
<div class="editor-nav-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_save_and_navigate"
|
||||
class="admin-btn admin-btn-sm admin-btn-primary"
|
||||
>
|
||||
Save and go
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_discard_and_navigate"
|
||||
class="admin-btn admin-btn-sm admin-btn-danger"
|
||||
>
|
||||
Don't save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="editor_cancel_navigate"
|
||||
class="admin-btn admin-btn-sm"
|
||||
>
|
||||
Stay here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user