add Site context with social links editor and site-wide settings
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:
jamey
2026-03-28 10:09:33 +00:00
parent 0b86cd66ce
commit 638bb4fb70
24 changed files with 3121 additions and 195 deletions

View File

@@ -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