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

@ -149,21 +149,33 @@ Close critical gaps identified in the competitive analysis. Phased approach: cor
| 100 | Blog post type | — | 3h | planned |
| 101 | Staff accounts & RBAC | — | 4h | planned |
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md))
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md)) — In Progress
Restructure the 3-tab editor panel for better discoverability. Replace Settings tab with Site tab for site-wide content (announcement bar text, social links, nav items, footer content). Move branding from Theme to Site. Merge page settings inline into Page tab.
| # | Task | Est | Status |
|---|------|-----|--------|
| 1-4 | Data model + Site tab skeleton | 3h | planned |
| 5-7 | Announcement bar (editable text, link, styles) | 1.5h | planned |
| 8-9 | Social links editor | 2h | planned |
| 1-4 | Data model + Site tab skeleton | 3h | done |
| 5-7 | Announcement bar (editable text, link, styles) | 1.5h | done |
| 8-9 | Social links editor | 2h | done |
| 10-14 | Header & footer navigation editors | 3h | planned |
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | planned |
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done |
| 17-18 | Move branding from Theme to Site | 1.5h | planned |
| 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | planned |
| 21-22 | Polish and testing | 2h | planned |
### Unified editor session ([plan](docs/plans/unified-editor-session.md)) — Complete
Unified "optimistic preview with explicit save" across all editor tabs. Changes show immediately but only persist on Save. Free tab switching without warnings. Navigation blocking with Save/Discard/Cancel modal.
| Phase | Description | Est | Status |
|-------|-------------|-----|--------|
| 1 | Site tab preview without auto-save | 1h | done |
| 2 | Theme tab preview without auto-save | 1.5h | done |
| 3 | Unified Save button (saves all dirty tabs) | 1h | done |
| 4 | Visual indicators (dirty dots on tabs, "Unsaved" text) | 0.5h | done |
| 5 | Navigation warning modal (Save & continue, Discard, Cancel) | 1h | done |
### SEO enhancements ([plan](docs/plans/seo-enhancements.md))
Comprehensive SEO tooling to rival Yoast/RankMath. Per-page SEO controls, enhanced schema, SEO preview panel, focus keyword with scoring, FAQ schema, Search Console integration.

View File

@ -3093,6 +3093,58 @@
border-bottom-color: var(--t-accent, oklch(0.6 0.15 250));
}
.editor-tab-dirty-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--t-status-warning, oklch(0.75 0.18 85));
margin-left: 4px;
vertical-align: middle;
}
/* ── Navigation warning modal ── */
.editor-nav-modal {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: oklch(0 0 0 / 0.5);
border: none;
padding: 1rem;
}
.editor-nav-modal-content {
background: white;
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 24px oklch(0 0 0 / 0.2);
& h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
}
& p {
margin: 0 0 1.25rem;
color: oklch(40% 0 0);
font-size: 0.875rem;
}
}
.editor-nav-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
/* ── Panel content ── */
.editor-panel-content {
flex: 1;
@ -3158,6 +3210,146 @@
overflow-y: auto;
}
/* ── Site editor ────────────────────────────────────────────────── */
.editor-site-content {
padding: 0.5rem;
}
.site-editor-section {
border: 1px solid var(--t-surface-sunken);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
background: var(--t-surface-base);
}
.site-editor-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
user-select: none;
color: var(--t-text-primary);
}
.site-editor-section-header::-webkit-details-marker {
display: none;
}
.site-editor-section-header .size-4 {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.6;
}
.site-editor-chevron {
margin-left: auto;
transition: transform 0.15s;
}
.site-editor-section[open] .site-editor-chevron {
transform: rotate(180deg);
}
.site-editor-section-content {
padding: 0 1rem 1rem;
}
.site-editor-placeholder {
padding: 0.75rem;
background: var(--t-surface-sunken);
border-radius: 0.375rem;
}
.site-editor-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.site-editor-radio-group {
display: flex;
gap: 1rem;
}
.site-editor-nav-list,
.site-editor-social-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.site-editor-nav-items,
.site-editor-social-items {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
.site-editor-nav-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--t-surface-sunken);
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.site-editor-nav-label {
font-weight: 500;
}
.site-editor-nav-url {
font-size: 0.75rem;
opacity: 0.6;
}
/* Social links editor item */
.site-editor-social-item {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
background: var(--t-surface-sunken);
border-radius: 0.375rem;
}
.site-editor-social-item-content {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 0;
& select {
width: 7rem;
flex-shrink: 0;
}
& input {
flex: 1;
min-width: 0;
}
}
.site-editor-social-item-actions {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
}
.site-editor-add-button {
align-self: flex-start;
margin-top: 0.25rem;
}
/*
Image field (block editor)
*/

View File

@ -679,7 +679,7 @@ const DirtyGuard = {
}
}
// EditorSheet: handles click-outside, Escape, and mobile drag-to-resize
// EditorSheet: handles click-outside, Escape, navigation guard, and mobile drag-to-resize
const EditorSheet = {
mounted() {
// Close on Escape key
@ -691,6 +691,37 @@ const EditorSheet = {
}
document.addEventListener("keydown", this._onKeydown)
// Navigation guard: warn on browser close/refresh
this._beforeUnload = (e) => {
if (this.el.dataset.dirty === "true") {
e.preventDefault()
e.returnValue = ""
}
}
window.addEventListener("beforeunload", this._beforeUnload)
// Navigation guard: intercept LiveView link clicks
this._clickGuard = (e) => {
if (this.el.dataset.dirty !== "true") return
const link = e.target.closest("a[data-phx-link]")
if (!link) return
// Don't block clicks inside the editor itself
if (this.el.contains(link)) return
// Block navigation and show custom modal
e.preventDefault()
e.stopImmediatePropagation()
this.pushEvent("editor_nav_blocked", { href: link.getAttribute("href") })
}
document.addEventListener("click", this._clickGuard, true)
// Handle navigation after save/discard
this.handleEvent("editor_navigate", ({ href }) => {
window.location.href = href
})
// Restore saved height on mobile and mark as already opened
this._restoreSavedHeight()
this._hasDragged = !!localStorage.getItem("editor-panel-height")
@ -712,6 +743,8 @@ const EditorSheet = {
destroyed() {
document.removeEventListener("keydown", this._onKeydown)
window.removeEventListener("beforeunload", this._beforeUnload)
document.removeEventListener("click", this._clickGuard, true)
this._cleanupDragHandle()
},
@ -882,35 +915,33 @@ const Clipboard = {
}
}
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
// Preserve <details> open state across LiveView re-renders
// Morphdom removes the open attribute when server doesn't send it,
// so we store the state locally and restore it after each update.
const DetailsPreserver = {
mounted() {
this._saveState()
},
beforeUpdate() {
this._saveState()
},
updated() {
this._restoreState()
},
_saveState() {
this._wasOpen = this.el.open
},
_restoreState() {
if (this._wasOpen !== undefined) {
this.el.open = this._wasOpen
}
}
}
// Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
// Note: Navigation guards are now handled by EditorSheet hook
const EditorKeyboard = {
mounted() {
this._beforeUnload = (e) => {
if (this.el.dataset.dirty === "true") {
e.preventDefault()
e.returnValue = ""
}
}
window.addEventListener("beforeunload", this._beforeUnload)
// Intercept LiveView navigation clicks when editor has unsaved changes.
// Uses capture phase to fire before LiveView's own click handler.
this._clickGuard = (e) => {
if (this.el.dataset.dirty !== "true") return
const link = e.target.closest("a[data-phx-link]")
if (!link) return
// Don't block clicks inside the editor itself (e.g. block controls)
if (this.el.contains(link)) return
if (!window.confirm("You have unsaved changes that will be lost. Leave anyway?")) {
e.preventDefault()
e.stopImmediatePropagation()
}
}
document.addEventListener("click", this._clickGuard, true)
const prefix = this.el.dataset.eventPrefix || ""
this._keydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
@ -926,8 +957,6 @@ const EditorKeyboard = {
},
destroyed() {
window.removeEventListener("beforeunload", this._beforeUnload)
document.removeEventListener("click", this._clickGuard, true)
document.removeEventListener("keydown", this._keydown)
}
}
@ -950,7 +979,7 @@ const Download = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DirtyGuard, EditorKeyboard, EditorSheet, Download},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DetailsPreserver, DirtyGuard, EditorKeyboard, EditorSheet, Download},
})
// Show progress bar on live navigation and form submits

View File

@ -13,7 +13,6 @@ config :berrypod, Berrypod.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#

View File

@ -0,0 +1,321 @@
# Unified Editor Session
**Status:** Complete
**Depends on:** Editor panel reorganisation (Phase 1-2 complete)
**Estimated effort:** 4-5 hours
## Overview
Unify the editing experience across all three tabs (Page, Theme, Site) so they behave consistently:
- **Instant preview** — all changes show immediately on the shop
- **Explicit save** — changes only persist to database when you click Save
- **Free tab switching** — no warnings when switching between tabs
- **Accumulated changes** — edits across all tabs are saved together
- **Navigation warning** — warn on page navigation or refresh if any tab has unsaved changes
## Current State
| Tab | Dirty Tracking | Save Method | Preview |
|-----|----------------|-------------|---------|
| Page | `editor_dirty` | Manual Save | Instant (in-memory blocks) |
| Theme | None | Auto-save | Instant (CSS regenerated) |
| Site | None | Auto-save | Instant (assigns updated) |
## Target State
| Tab | Dirty Tracking | Save Method | Preview |
|-----|----------------|-------------|---------|
| Page | `page_dirty` | Unified Save | Instant (in-memory blocks) |
| Theme | `theme_dirty` | Unified Save | Instant (in-memory settings) |
| Site | `site_dirty` | Unified Save | Instant (in-memory settings) |
Unified dirty flag: `editor_dirty = page_dirty || theme_dirty || site_dirty`
## Implementation Tasks
### Phase 1: Theme Tab — Preview Without Auto-Save (1.5h)
Currently Theme auto-saves every change. We need to decouple preview from persistence.
#### 1.1 Add theme dirty tracking
In `page_editor_hook.ex`, add assigns:
```elixir
|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, nil) # snapshot at load time
```
#### 1.2 Modify theme event handlers
Change handlers like `theme_update_setting`, `theme_update_color`, etc. to:
1. Update `theme_editor_settings` in memory (for preview)
2. Regenerate CSS for live preview
3. Set `theme_dirty = true`
4. **Do NOT call `Settings.update_theme_settings()`**
#### 1.3 Store original theme state
When loading theme state in `load_theme_state/1`:
```elixir
|> assign(:theme_editor_original, theme_settings)
```
#### 1.4 Add theme revert logic
On discard/close with dirty state, restore from `theme_editor_original`.
### Phase 2: Site Tab — Preview Without Auto-Save (1h)
#### 2.1 Add site dirty tracking
```elixir
|> assign(:site_dirty, false)
|> assign(:site_editor_original, nil)
```
#### 2.2 Modify site event handlers
Change `handle_site_update/2` to:
1. Update `site_*` assigns in memory (for preview)
2. Set `site_dirty = true`
3. **Do NOT call `Site.put_announcement()` or `Site.put_footer_content()`**
#### 2.3 Store original site state
When loading site state in `load_site_state/1`:
```elixir
original = %{
announcement_text: site_settings.announcement_text,
announcement_link: site_settings.announcement_link,
announcement_style: site_settings.announcement_style,
footer_about: site_settings.footer_about,
footer_copyright: site_settings.footer_copyright,
show_newsletter: site_settings.show_newsletter
}
|> assign(:site_editor_original, original)
```
### Phase 3: Unified Save Button (1h)
#### 3.1 Compute unified dirty state
```elixir
defp compute_editor_dirty(socket) do
page_dirty = socket.assigns[:editor_dirty] || false
theme_dirty = socket.assigns[:theme_dirty] || false
site_dirty = socket.assigns[:site_dirty] || false
settings_dirty = socket.assigns[:settings_dirty] || false
assign(socket, :any_dirty, page_dirty || theme_dirty || site_dirty || settings_dirty)
end
```
#### 3.2 Update Save button
In `editor_sheet` header, change Save button to:
- Enabled when `@any_dirty`
- On click: `editor_save_all` event
#### 3.3 Implement unified save handler
```elixir
defp handle_editor_event("editor_save_all", _params, socket) do
socket = save_all_tabs(socket)
{:halt, socket}
end
defp save_all_tabs(socket) do
socket
|> maybe_save_page()
|> maybe_save_theme()
|> maybe_save_site()
|> maybe_save_settings()
|> assign(:editor_save_status, :saved)
|> schedule_save_status_clear()
end
defp maybe_save_page(socket) do
if socket.assigns[:editor_dirty] do
# existing page save logic
else
socket
end
end
defp maybe_save_theme(socket) do
if socket.assigns[:theme_dirty] do
Settings.update_theme_settings(socket.assigns.theme_editor_settings)
assign(socket, :theme_dirty, false)
else
socket
end
end
defp maybe_save_site(socket) do
if socket.assigns[:site_dirty] do
site = socket.assigns
Site.put_announcement(site.site_announcement_text, site.site_announcement_link, site.site_announcement_style)
Site.put_footer_content(site.site_footer_about, site.site_footer_copyright, site.site_footer_show_newsletter)
assign(socket, :site_dirty, false)
else
socket
end
end
```
### Phase 4: Visual Indicators (0.5h)
#### 4.1 Dirty dots on tab labels
In `editor_sheet` tablist:
```heex
<button role="tab" ...>
Page
<span :if={@page_dirty} class="editor-tab-dirty-dot" aria-label="unsaved changes"></span>
</button>
```
CSS:
```css
.editor-tab-dirty-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-warning);
margin-left: 4px;
}
```
#### 4.2 Update EditorKeyboard hook
Pass unified dirty state to the hook:
```heex
<div id="editor-sheet-inner"
phx-hook="EditorKeyboard"
data-dirty={to_string(@any_dirty)}>
```
### Phase 5: Navigation Warning Flow (1h)
#### 5.1 Update EditorKeyboard JS hook
Modify the navigation intercept in `assets/js/app.js`:
```javascript
// On navigation attempt when dirty
if (this.el.dataset.dirty === "true") {
e.preventDefault();
e.stopImmediatePropagation();
// Send event to LiveView to show modal
this.pushEvent("editor_nav_blocked", { href: href });
}
```
#### 5.2 Add navigation modal component
```heex
<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. What would you like to do?</p>
<div class="editor-nav-modal-actions">
<button phx-click="editor_save_and_navigate">Save & continue</button>
<button phx-click="editor_discard_and_navigate">Discard & continue</button>
<button phx-click="editor_cancel_navigate">Cancel</button>
</div>
</div>
</dialog>
```
#### 5.3 Handle modal events
```elixir
defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
{:halt, assign(socket, editor_nav_blocked: href)}
end
defp handle_editor_event("editor_save_and_navigate", _params, socket) do
socket = save_all_tabs(socket)
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
end
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
socket = revert_all_tabs(socket)
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
end
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
# Re-open sheet if collapsed
socket =
socket
|> assign(:editor_nav_blocked, nil)
|> assign(:editor_sheet_state, :open)
{:halt, socket}
end
```
#### 5.4 Add revert logic
```elixir
defp revert_all_tabs(socket) do
socket
|> maybe_revert_page()
|> maybe_revert_theme()
|> maybe_revert_site()
|> assign(:any_dirty, false)
end
defp maybe_revert_theme(socket) do
if socket.assigns[:theme_dirty] do
original = socket.assigns.theme_editor_original
# Regenerate CSS from original
# Reset theme_editor_settings to original
else
socket
end
end
```
## Files to Modify
| File | Changes |
|------|---------|
| `lib/berrypod_web/page_editor_hook.ex` | Dirty tracking, save handlers, revert logic |
| `lib/berrypod_web/components/shop_components/layout.ex` | Tab dirty dots, Save button, nav modal |
| `lib/berrypod_web/page_renderer.ex` | Pass unified dirty state to editor_sheet |
| `assets/js/app.js` | EditorKeyboard nav intercept |
| `assets/css/admin/components.css` | Dirty dot styles, nav modal styles |
## Testing Plan
1. **Page tab**: Edit blocks → switch tabs → switch back → verify changes preserved
2. **Theme tab**: Change color → switch tabs → verify preview still shows new color
3. **Site tab**: Edit announcement → switch tabs → verify preview shows new text
4. **Save all**: Make changes in all tabs → click Save → verify all persisted
5. **Navigate away**: Make changes → click link → verify warning modal
6. **Save & continue**: Warning modal → Save & continue → verify saved and navigated
7. **Discard & continue**: Warning modal → Discard → verify reverted and navigated
8. **Cancel**: Warning modal → Cancel → verify sheet opens, changes preserved
9. **Browser refresh**: Make changes → F5 → verify beforeunload warning
10. **Tab close**: Make changes → close tab → verify beforeunload warning
## Rollback Plan
If issues arise, the immediate fix is to re-enable auto-save for Theme/Site tabs by calling the persistence functions in the event handlers again. The dirty tracking can remain in place.
## Future Enhancements
- **Undo/redo for Theme/Site**: Currently only Page tab has history
- **Draft persistence**: Save drafts to localStorage for recovery after crash
- **Keyboard shortcuts**: Cmd+S to save all

View File

@ -176,7 +176,6 @@ defmodule Berrypod.Backup do
end
defp process_table_stats(tables) do
table_names = Enum.map(tables, fn [name] -> name end)
# Get sizes via dbstat if available
@ -696,7 +695,9 @@ defmodule Berrypod.Backup do
# Verify we can actually query
try do
case Repo.query("SELECT 1") do
{:ok, _} -> :ok
{:ok, _} ->
:ok
{:error, _} ->
Process.sleep(100)
wait_for_repo(attempts - 1)
@ -755,7 +756,6 @@ defmodule Berrypod.Backup do
:ok
end
defp clear_ets_caches do
# Clear known ETS caches to ensure they get rebuilt from the new database
caches = [

431
lib/berrypod/site.ex Normal file
View File

@ -0,0 +1,431 @@
defmodule Berrypod.Site do
@moduledoc """
The Site context for managing site-wide content.
This includes navigation items, social links, announcement bar,
and footer content everything that appears across all pages.
"""
import Ecto.Query, warn: false
alias Berrypod.Repo
alias Berrypod.Site.{NavItem, SocialLink}
alias Berrypod.Settings
# ── Navigation items ───────────────────────────────────────────────
@doc """
Lists all navigation items for a location, ordered by position.
"""
def list_nav_items(location) when location in [:header, :footer, "header", "footer"] do
location = to_string(location)
NavItem
|> where([n], n.location == ^location)
|> order_by([n], asc: n.position)
|> Repo.all()
end
@doc """
Gets a single nav item by ID.
"""
def get_nav_item!(id), do: Repo.get!(NavItem, id)
@doc """
Creates a nav item.
"""
def create_nav_item(attrs \\ %{}) do
%NavItem{}
|> NavItem.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a nav item.
"""
def update_nav_item(%NavItem{} = nav_item, attrs) do
nav_item
|> NavItem.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a nav item.
"""
def delete_nav_item(%NavItem{} = nav_item) do
Repo.delete(nav_item)
end
@doc """
Returns a changeset for tracking nav item changes.
"""
def change_nav_item(%NavItem{} = nav_item, attrs \\ %{}) do
NavItem.changeset(nav_item, attrs)
end
@doc """
Reorders nav items by updating positions.
Takes a list of nav item IDs in the desired order.
"""
def reorder_nav_items(ids) when is_list(ids) do
Repo.transaction(fn ->
ids
|> Enum.with_index()
|> Enum.each(fn {id, position} ->
from(n in NavItem, where: n.id == ^id)
|> Repo.update_all(set: [position: position])
end)
end)
end
@doc """
Returns nav items formatted for shop components.
Converts NavItem structs to the map format expected by layout components.
"""
def nav_items_for_shop(location) do
list_nav_items(location)
|> Enum.map(&nav_item_to_map/1)
end
defp nav_item_to_map(%NavItem{} = item) do
%{
"label" => item.label,
"href" => item.url,
"slug" => slug_from_url(item.url),
"page_id" => item.page_id
}
end
defp slug_from_url("/"), do: "home"
defp slug_from_url("/collections" <> _), do: "collection"
defp slug_from_url("/products" <> _), do: "pdp"
defp slug_from_url("/" <> rest), do: String.split(rest, "/") |> List.first() || ""
defp slug_from_url(_), do: ""
# ── Social links ───────────────────────────────────────────────────
@doc """
Lists all social links, ordered by position.
"""
def list_social_links do
SocialLink
|> order_by([s], asc: s.position)
|> Repo.all()
end
@doc """
Gets a single social link by ID.
"""
def get_social_link!(id), do: Repo.get!(SocialLink, id)
@doc """
Creates a social link.
"""
def create_social_link(attrs \\ %{}) do
%SocialLink{}
|> SocialLink.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a social link.
"""
def update_social_link(%SocialLink{} = social_link, attrs) do
social_link
|> SocialLink.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a social link.
"""
def delete_social_link(%SocialLink{} = social_link) do
Repo.delete(social_link)
end
@doc """
Returns a changeset for tracking social link changes.
"""
def change_social_link(%SocialLink{} = social_link, attrs \\ %{}) do
SocialLink.changeset(social_link, attrs)
end
@doc """
Reorders social links by updating positions.
"""
def reorder_social_links(ids) when is_list(ids) do
Repo.transaction(fn ->
ids
|> Enum.with_index()
|> Enum.each(fn {id, position} ->
from(s in SocialLink, where: s.id == ^id)
|> Repo.update_all(set: [position: position])
end)
end)
end
@doc """
Returns social links formatted for shop components.
Filters out links with empty URLs (incomplete entries from the editor).
"""
def social_links_for_shop do
list_social_links()
|> Enum.reject(fn link -> is_nil(link.url) or link.url == "" end)
|> Enum.map(fn link ->
%{
platform: String.to_existing_atom(link.platform),
url: link.url,
label: platform_label(link.platform)
}
end)
end
defp platform_label("instagram"), do: "Instagram"
defp platform_label("pinterest"), do: "Pinterest"
defp platform_label("tiktok"), do: "TikTok"
defp platform_label("facebook"), do: "Facebook"
defp platform_label("twitter"), do: "Twitter"
defp platform_label("youtube"), do: "YouTube"
defp platform_label("patreon"), do: "Patreon"
defp platform_label("kofi"), do: "Ko-fi"
defp platform_label("etsy"), do: "Etsy"
defp platform_label("gumroad"), do: "Gumroad"
defp platform_label("bandcamp"), do: "Bandcamp"
defp platform_label("mastodon"), do: "Mastodon"
defp platform_label("pixelfed"), do: "Pixelfed"
defp platform_label("bluesky"), do: "Bluesky"
defp platform_label("peertube"), do: "PeerTube"
defp platform_label("lemmy"), do: "Lemmy"
defp platform_label("matrix"), do: "Matrix"
defp platform_label("github"), do: "GitHub"
defp platform_label("gitlab"), do: "GitLab"
defp platform_label("codeberg"), do: "Codeberg"
defp platform_label("sourcehut"), do: "SourceHut"
defp platform_label(other), do: String.capitalize(other)
# ── Announcement bar ───────────────────────────────────────────────
@doc """
Gets the announcement bar text.
"""
def announcement_text do
Settings.get_setting("announcement_text", "")
end
@doc """
Sets the announcement bar text.
"""
def set_announcement_text(text) when is_binary(text) do
Settings.put_setting("announcement_text", text)
end
@doc """
Gets the announcement bar link URL.
"""
def announcement_link do
Settings.get_setting("announcement_link", "")
end
@doc """
Sets the announcement bar link URL.
"""
def set_announcement_link(url) when is_binary(url) do
Settings.put_setting("announcement_link", url)
end
@doc """
Gets the announcement bar style (info, sale, warning).
"""
def announcement_style do
Settings.get_setting("announcement_style", "info")
end
@doc """
Sets the announcement bar style.
"""
def set_announcement_style(style) when style in ["info", "sale", "warning"] do
Settings.put_setting("announcement_style", style)
end
# ── Footer content ─────────────────────────────────────────────────
@doc """
Gets the footer about text.
"""
def footer_about do
Settings.get_setting("footer_about", "")
end
@doc """
Sets the footer about text.
"""
def set_footer_about(text) when is_binary(text) do
Settings.put_setting("footer_about", text)
end
@doc """
Gets the footer copyright text.
Returns empty string if not set (caller should generate default).
"""
def footer_copyright do
Settings.get_setting("footer_copyright", "")
end
@doc """
Sets the footer copyright text.
Pass empty string to use auto-generated copyright.
"""
def set_footer_copyright(text) when is_binary(text) do
Settings.put_setting("footer_copyright", text)
end
@doc """
Returns whether the newsletter signup should be shown in the footer.
"""
def show_newsletter? do
Settings.get_setting("show_newsletter", true) == true
end
@doc """
Sets whether the newsletter signup should be shown in the footer.
"""
def set_show_newsletter(show?) when is_boolean(show?) do
Settings.put_setting("show_newsletter", show?, "boolean")
end
# ── Bulk updates ───────────────────────────────────────────────────
@doc """
Updates the announcement bar settings.
"""
def put_announcement(text, link, style) do
set_announcement_text(text)
set_announcement_link(link)
set_announcement_style(style)
:ok
end
@doc """
Updates the footer content settings.
"""
def put_footer_content(about, copyright, show_newsletter) do
set_footer_about(about)
set_footer_copyright(copyright)
set_show_newsletter(show_newsletter)
:ok
end
@doc """
Updates multiple site settings at once.
Accepts a map with keys like :announcement_text, :footer_about, etc.
"""
def update_settings(attrs) when is_map(attrs) do
Enum.each(attrs, fn
{:announcement_text, value} -> set_announcement_text(value || "")
{:announcement_link, value} -> set_announcement_link(value || "")
{:announcement_style, value} -> set_announcement_style(value || "info")
{:footer_about, value} -> set_footer_about(value || "")
{:footer_copyright, value} -> set_footer_copyright(value || "")
{:show_newsletter, value} -> set_show_newsletter(value == true)
_ -> :ok
end)
:ok
end
@doc """
Returns all site settings as a map.
"""
def get_settings do
%{
announcement_text: announcement_text(),
announcement_link: announcement_link(),
announcement_style: announcement_style(),
footer_about: footer_about(),
footer_copyright: footer_copyright(),
show_newsletter: show_newsletter?()
}
end
# ── Seeding ────────────────────────────────────────────────────────
@default_header_nav [
%{label: "Home", url: "/", position: 0},
%{label: "Shop", url: "/collections/all", position: 1},
%{label: "About", url: "/about", position: 2},
%{label: "Contact", url: "/contact", position: 3}
]
@default_footer_nav [
%{label: "Delivery & returns", url: "/delivery", position: 0},
%{label: "Privacy policy", url: "/privacy", position: 1},
%{label: "Terms of service", url: "/terms", position: 2},
%{label: "Contact", url: "/contact", position: 3}
]
@default_social_links [
%{platform: "instagram", url: "https://instagram.com", position: 0},
%{platform: "bluesky", url: "https://bsky.app", position: 1}
]
@doc """
Seeds default navigation items and social links if none exist.
Safe to call multiple times only inserts if tables are empty.
"""
def seed_defaults do
seed_nav_items()
seed_social_links()
:ok
end
defp seed_nav_items do
if Repo.aggregate(NavItem, :count) == 0 do
now = DateTime.utc_now() |> DateTime.truncate(:second)
header_items =
Enum.map(@default_header_nav, fn item ->
Map.merge(item, %{
id: Ecto.UUID.generate(),
location: "header",
inserted_at: now,
updated_at: now
})
end)
footer_items =
Enum.map(@default_footer_nav, fn item ->
Map.merge(item, %{
id: Ecto.UUID.generate(),
location: "footer",
inserted_at: now,
updated_at: now
})
end)
Repo.insert_all(NavItem, header_items ++ footer_items)
end
end
defp seed_social_links do
if Repo.aggregate(SocialLink, :count) == 0 do
now = DateTime.utc_now() |> DateTime.truncate(:second)
links =
Enum.map(@default_social_links, fn item ->
Map.merge(item, %{
id: Ecto.UUID.generate(),
inserted_at: now,
updated_at: now
})
end)
Repo.insert_all(SocialLink, links)
end
end
end

View File

@ -0,0 +1,32 @@
defmodule Berrypod.Site.NavItem do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@locations ~w(header footer)
schema "nav_items" do
field :location, :string
field :label, :string
field :url, :string
field :position, :integer, default: 0
belongs_to :page, Berrypod.Pages.Page
timestamps(type: :utc_datetime)
end
def locations, do: @locations
@doc false
def changeset(nav_item, attrs) do
nav_item
|> cast(attrs, [:location, :label, :url, :page_id, :position])
|> validate_required([:location, :label, :url])
|> validate_inclusion(:location, @locations)
|> validate_length(:label, min: 1, max: 50)
|> foreign_key_constraint(:page_id)
end
end

View File

@ -0,0 +1,216 @@
defmodule Berrypod.Site.SocialLink do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
# Grouped by category for the editor dropdown
@platform_groups [
{"Social", ~w(instagram threads tiktok facebook twitter snapchat linkedin)},
{"Video & streaming", ~w(youtube twitch vimeo kick rumble)},
{"Music & podcasts", ~w(spotify soundcloud bandcamp applepodcasts)},
{"Creative", ~w(pinterest behance dribbble tumblr medium)},
{"Support & sales", ~w(patreon kofi etsy gumroad substack)},
{"Federated", ~w(mastodon pixelfed bluesky peertube lemmy matrix)},
{"Developer", ~w(github gitlab codeberg sourcehut reddit)},
{"Messaging", ~w(discord telegram signal whatsapp)},
{"Other", ~w(linktree rss website custom)}
]
@platforms @platform_groups |> Enum.flat_map(fn {_, platforms} -> platforms end)
schema "social_links" do
field :platform, :string
field :url, :string
field :position, :integer, default: 0
timestamps(type: :utc_datetime)
end
def platforms, do: @platforms
def platform_groups, do: @platform_groups
@doc """
Normalizes a URL by trimming whitespace and adding https:// if missing.
Returns the normalized URL string, or the original if empty/nil.
"""
def normalize_url(nil), do: nil
def normalize_url(""), do: ""
def normalize_url(url) when is_binary(url) do
url = String.trim(url)
cond do
url == "" -> ""
# Preserve existing protocols (http, https, and app-specific deep links)
String.contains?(url, "://") -> url
# Preserve other URI schemes (mailto:, tel:, etc.)
Regex.match?(~r/^[a-z][a-z0-9+.-]*:/i, url) -> url
# Default to https for bare domains
true -> "https://" <> url
end
end
@doc """
Detects the platform from a URL by matching the domain.
Returns the platform string if detected, or "custom" for unknown domains.
Returns nil for invalid URLs. Automatically normalizes the URL first.
"""
def detect_platform(url) when is_binary(url) do
url
|> normalize_url()
|> do_detect_platform()
end
def detect_platform(_), do: nil
defp do_detect_platform(""), do: nil
defp do_detect_platform(nil), do: nil
# Scheme-to-platform mapping for app deep links and custom protocols
@scheme_platforms %{
# RSS/feeds
"rss" => "rss",
"feed" => "rss",
# Social
"instagram" => "instagram",
"fb" => "facebook",
"twitter" => "twitter",
"snapchat" => "snapchat",
"linkedin" => "linkedin",
# Video & streaming
"youtube" => "youtube",
"vnd.youtube" => "youtube",
"twitch" => "twitch",
"vimeo" => "vimeo",
# Music & podcasts
"spotify" => "spotify",
"soundcloud" => "soundcloud",
"bandcamp" => "bandcamp",
"podcasts" => "applepodcasts",
"itms-podcasts" => "applepodcasts",
# Creative
"pinterest" => "pinterest",
"tumblr" => "tumblr",
# Support & sales
"patreon" => "patreon",
# Federated
"matrix" => "matrix",
# Developer
"github" => "github",
"github-mac" => "github",
"github-windows" => "github",
"x-github-client" => "github",
"gitlab" => "gitlab",
"reddit" => "reddit",
# Messaging
"discord" => "discord",
"tg" => "telegram",
"telegram" => "telegram",
"sgnl" => "signal",
"signal" => "signal",
"whatsapp" => "whatsapp"
}
defp do_detect_platform(url) do
case URI.parse(url) do
# Scheme-based detection for app deep links
%URI{scheme: scheme} when is_map_key(@scheme_platforms, scheme) ->
@scheme_platforms[scheme]
# Host-based detection (the common case)
%URI{host: host} when is_binary(host) ->
host
|> String.downcase()
|> String.replace_prefix("www.", "")
|> detect_from_host()
_ ->
nil
end
end
# Host-to-platform mapping for domain-based detection
@host_platforms %{
# Social
"instagram.com" => "instagram", "threads.net" => "threads", "tiktok.com" => "tiktok",
"facebook.com" => "facebook", "fb.com" => "facebook",
"twitter.com" => "twitter", "x.com" => "twitter",
"snapchat.com" => "snapchat", "linkedin.com" => "linkedin",
# Video & streaming
"youtube.com" => "youtube", "youtu.be" => "youtube",
"twitch.tv" => "twitch", "vimeo.com" => "vimeo",
"kick.com" => "kick", "rumble.com" => "rumble",
# Music & podcasts
"spotify.com" => "spotify", "open.spotify.com" => "spotify",
"soundcloud.com" => "soundcloud", "bandcamp.com" => "bandcamp",
"podcasts.apple.com" => "applepodcasts",
# Creative
"pinterest.com" => "pinterest", "pin.it" => "pinterest",
"behance.net" => "behance", "dribbble.com" => "dribbble",
"tumblr.com" => "tumblr", "medium.com" => "medium",
# Support & sales
"patreon.com" => "patreon", "ko-fi.com" => "kofi",
"etsy.com" => "etsy", "gumroad.com" => "gumroad", "substack.com" => "substack",
# Federated
"mastodon.social" => "mastodon", "pixelfed.social" => "pixelfed",
"bsky.app" => "bluesky", "lemmy.world" => "lemmy", "matrix.to" => "matrix",
# Developer
"github.com" => "github", "gitlab.com" => "gitlab",
"codeberg.org" => "codeberg", "sr.ht" => "sourcehut",
"reddit.com" => "reddit", "old.reddit.com" => "reddit",
# Messaging
"discord.com" => "discord", "discord.gg" => "discord",
"t.me" => "telegram", "telegram.me" => "telegram",
"signal.me" => "signal", "signal.group" => "signal",
"wa.me" => "whatsapp", "whatsapp.com" => "whatsapp",
# Other
"linktr.ee" => "linktree"
}
# Subdomain suffixes for user-specific URLs (e.g., user.bandcamp.com)
@subdomain_platforms [
{".bandcamp.com", "bandcamp"},
{".substack.com", "substack"},
{".tumblr.com", "tumblr"},
{".medium.com", "medium"}
]
defp detect_from_host(host) do
Map.get_lazy(@host_platforms, host, fn ->
Enum.find_value(@subdomain_platforms, "custom", fn {suffix, platform} ->
if String.ends_with?(host, suffix), do: platform
end)
end)
end
@doc false
def changeset(social_link, attrs) do
social_link
|> cast(attrs, [:platform, :url, :position])
|> validate_required([:platform])
|> validate_inclusion(:platform, @platforms)
|> validate_url(:url)
end
defp validate_url(changeset, field) do
validate_change(changeset, field, fn _, value ->
# Allow empty/blank URLs (for newly added links)
if value == "" or is_nil(value) do
[]
else
case URI.parse(value) do
# Accept any URL with a valid scheme (http, https, app deep links, etc.)
%URI{scheme: scheme} when is_binary(scheme) and scheme != "" ->
[]
_ ->
[{field, "must be a valid URL"}]
end
end
end)
end
end

View File

@ -713,6 +713,143 @@ defmodule BerrypodWeb.ShopComponents.Content do
"""
end
# Additional social platforms
defp social_icon(%{platform: :linkedin} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
"""
end
defp social_icon(%{platform: :threads} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z" />
</svg>
"""
end
defp social_icon(%{platform: :whatsapp} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
"""
end
defp social_icon(%{platform: :twitch} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
</svg>
"""
end
defp social_icon(%{platform: :spotify} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
"""
end
defp social_icon(%{platform: :soundcloud} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M1.175 12.225c-.051 0-.094.046-.101.1l-.233 2.154.233 2.105c.007.058.05.098.101.098.05 0 .09-.04.099-.098l.255-2.105-.27-2.154c0-.057-.045-.1-.09-.1m-.899.828c-.06 0-.091.037-.104.094L0 14.479l.165 1.308c0 .055.045.094.09.094s.089-.045.104-.104l.21-1.319-.21-1.334c0-.061-.044-.09-.09-.09m1.83-1.229c-.061 0-.12.045-.12.104l-.21 2.563.225 2.458c0 .06.045.12.119.12.061 0 .105-.061.121-.12l.254-2.474-.254-2.548c-.016-.06-.061-.12-.121-.12m.945-.089c-.075 0-.135.06-.15.135l-.193 2.64.21 2.544c.016.077.075.138.149.138.075 0 .135-.061.15-.15l.24-2.532-.24-2.623c0-.075-.06-.135-.135-.135l-.031-.017zm1.155.36c-.005-.09-.075-.149-.159-.149-.09 0-.158.06-.164.149l-.217 2.43.2 2.563c0 .09.075.157.159.157.074 0 .148-.068.148-.158l.227-2.563-.227-2.444.033.015zm.809-1.709c-.101 0-.18.09-.18.181l-.21 3.957.187 2.563c0 .09.08.164.18.164.094 0 .174-.09.18-.18l.209-2.563-.209-3.972c-.008-.104-.088-.18-.18-.18m.959-.914c-.105 0-.195.09-.203.194l-.18 4.872.165 2.548c0 .12.09.209.195.209.104 0 .194-.089.21-.209l.193-2.548-.192-4.856c-.016-.12-.105-.21-.21-.21m.989-.449c-.121 0-.211.089-.225.209l-.165 5.275.165 2.52c.014.119.104.225.225.225.119 0 .225-.105.225-.225l.195-2.52-.196-5.275c0-.12-.105-.225-.225-.225m1.245.045c0-.135-.105-.24-.24-.24-.119 0-.24.105-.24.24l-.149 5.441.149 2.503c.016.135.121.24.256.24s.24-.105.24-.24l.164-2.503-.164-5.456-.016.015zm.749-.134c-.135 0-.255.119-.255.254l-.15 5.322.15 2.473c0 .15.12.255.255.255s.255-.12.255-.27l.15-2.474-.165-5.307c0-.148-.12-.27-.271-.27m1.005.166c-.164 0-.284.135-.284.285l-.103 5.143.135 2.474c0 .149.119.277.284.277.149 0 .271-.12.284-.285l.121-2.443-.135-5.112c-.012-.164-.135-.285-.285-.285m1.184-.945c-.045-.029-.105-.044-.165-.044s-.119.015-.165.044c-.09.054-.149.15-.149.255v.061l-.104 6.048.115 2.449v.008c.008.06.03.135.074.18.058.061.142.104.234.104.08 0 .158-.044.209-.09.058-.06.091-.135.091-.225l.015-.24.117-2.203-.135-6.086c0-.104-.061-.193-.135-.239l-.002-.022zm1.006-.547c-.045-.045-.09-.061-.15-.061-.074 0-.149.016-.209.061-.075.061-.119.15-.119.24v.029l-.137 6.609.076 1.215.061 1.185c0 .164.148.314.328.314.181 0 .33-.15.33-.329l.15-2.414-.15-6.637c0-.12-.074-.221-.165-.277m8.934 3.777c-.405 0-.795.086-1.139.232-.24-2.654-2.46-4.736-5.188-4.736-.659 0-1.305.135-1.889.359-.225.09-.27.18-.285.359v9.368c.016.18.15.33.33.345h8.185C22.681 17.218 24 15.914 24 14.28s-1.319-2.952-2.938-2.952" />
</svg>
"""
end
defp social_icon(%{platform: :vimeo} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
</svg>
"""
end
defp social_icon(%{platform: :behance} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.969 16.927a2.561 2.561 0 0 0 1.901.677 2.501 2.501 0 0 0 1.531-.475c.362-.235.636-.584.779-.99h2.585a5.091 5.091 0 0 1-1.9 2.896 5.292 5.292 0 0 1-3.091.88 5.839 5.839 0 0 1-2.284-.433 4.871 4.871 0 0 1-1.723-1.211 5.657 5.657 0 0 1-1.08-1.874 7.057 7.057 0 0 1-.383-2.393c-.005-.8.129-1.595.396-2.349a5.313 5.313 0 0 1 5.088-3.604 4.87 4.87 0 0 1 2.376.563c.661.362 1.231.87 1.668 1.485a6.2 6.2 0 0 1 .943 2.133c.194.821.263 1.666.205 2.508h-7.699c-.063.79.184 1.574.688 2.187ZM6.947 4.084a8.065 8.065 0 0 1 1.928.198 4.29 4.29 0 0 1 1.49.638c.418.303.748.711.958 1.182.241.579.357 1.203.341 1.83a3.506 3.506 0 0 1-.506 1.961 3.726 3.726 0 0 1-1.503 1.287 3.588 3.588 0 0 1 2.027 1.437c.464.747.697 1.615.67 2.494a4.593 4.593 0 0 1-.423 2.032 3.945 3.945 0 0 1-1.163 1.413 5.114 5.114 0 0 1-1.683.807 7.135 7.135 0 0 1-1.928.259H0V4.084h6.947Zm-.235 12.9c.308.004.616-.029.916-.099a2.18 2.18 0 0 0 .766-.332c.228-.158.411-.371.534-.619.142-.317.208-.663.191-1.009a2.08 2.08 0 0 0-.642-1.715 2.618 2.618 0 0 0-1.696-.505h-3.54v4.279h3.471Zm13.635-5.967a2.13 2.13 0 0 0-1.654-.619 2.336 2.336 0 0 0-1.163.259 2.474 2.474 0 0 0-.738.62 2.359 2.359 0 0 0-.396.792c-.074.239-.12.485-.137.734h4.769a3.239 3.239 0 0 0-.679-1.785l-.002-.001Zm-13.813-.648a2.254 2.254 0 0 0 1.423-.433c.399-.355.607-.88.56-1.413a1.916 1.916 0 0 0-.178-.891 1.298 1.298 0 0 0-.495-.533 1.851 1.851 0 0 0-.711-.274 3.966 3.966 0 0 0-.835-.073H3.241v3.631h3.293v-.014ZM21.62 5.122h-5.976v1.527h5.976V5.122Z" />
</svg>
"""
end
defp social_icon(%{platform: :dribbble} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm10.12-10.358c-.35-.11-3.17-.953-6.384-.438 1.34 3.684 1.887 6.684 1.992 7.308 2.3-1.555 3.936-4.02 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4 1.73 1.358 3.92 2.166 6.29 2.166 1.42 0 2.77-.29 4-.814zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765.135-.045.27-.084.405-.12-.26-.585-.54-1.167-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248-1.698-3.018-3.53-5.558-3.8-5.928-2.868 1.35-5.01 3.99-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6 3.645-1.365 5.19-3.44 5.373-3.702-1.81-1.61-4.19-2.586-6.795-2.586-.825 0-1.63.1-2.4.285zm10.335 3.483c-.218.29-1.935 2.493-5.724 4.04.24.49.47.985.68 1.486.08.18.15.36.22.53 3.41-.43 6.8.26 7.14.33-.02-2.42-.88-4.64-2.31-6.38z" />
</svg>
"""
end
defp social_icon(%{platform: :linktree} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z" />
</svg>
"""
end
defp social_icon(%{platform: :snapchat} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" />
</svg>
"""
end
defp social_icon(%{platform: :reddit} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" />
</svg>
"""
end
defp social_icon(%{platform: :medium} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.54 12a6.8 6.8 0 01-6.77 6.82A6.8 6.8 0 010 12a6.8 6.8 0 016.77-6.82A6.8 6.8 0 0113.54 12zM20.96 12c0 3.54-1.51 6.42-3.38 6.42-1.87 0-3.39-2.88-3.39-6.42s1.52-6.42 3.39-6.42 3.38 2.88 3.38 6.42M24 12c0 3.17-.53 5.75-1.19 5.75-.66 0-1.19-2.58-1.19-5.75s.53-5.75 1.19-5.75C23.47 6.25 24 8.83 24 12z" />
</svg>
"""
end
defp social_icon(%{platform: :tumblr} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />
</svg>
"""
end
defp social_icon(%{platform: :applepodcasts} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z" />
</svg>
"""
end
defp social_icon(%{platform: :kick} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z" />
</svg>
"""
end
defp social_icon(%{platform: :rumble} = assigns) do
~H"""
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.4528 13.5458c.8064-.6542.9297-1.8381.2756-2.6445a1.8802 1.8802 0 0 0-.2756-.2756 21.2127 21.2127 0 0 0-4.3121-2.776c-1.066-.51-2.256.2-2.4261 1.414a23.5226 23.5226 0 0 0-.14 5.5021c.116 1.23 1.292 1.964 2.372 1.492a19.6285 19.6285 0 0 0 4.5062-2.704v-.008zm6.9322-5.4002c2.0335 2.228 2.0396 5.637.014 7.8723A26.1487 26.1487 0 0 1 8.2946 23.846c-2.6848.6713-5.4168-.914-6.1662-3.5781-1.524-5.2002-1.3-11.0803.17-16.3045.772-2.744 3.3521-4.4661 6.0102-3.832 4.9242 1.174 9.5443 4.196 13.0764 8.0121v.002z" />
</svg>
"""
end
# Fallback for unknown platforms
defp social_icon(assigns) do
~H"""

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

View File

@ -0,0 +1,420 @@
defmodule BerrypodWeb.ShopComponents.SiteEditor do
@moduledoc """
Site editor component for the on-site editor panel.
Manages site-wide content that appears across all pages:
- Branding (shop name, logo, favicon)
- Announcement bar
- Header navigation
- Footer content & navigation
- Social links
"""
use Phoenix.Component
import BerrypodWeb.CoreComponents, only: [icon: 1]
# ── Main Editor Component ────────────────────────────────────────────
@doc """
Renders the site editor panel.
Shows collapsible sections for each category of site-wide content.
Expects assigns from the page editor hook:
- site_header_nav, site_footer_nav, site_social_links
- site_announcement_text, site_announcement_link, site_announcement_style
- site_footer_about, site_footer_copyright, site_footer_show_newsletter
"""
attr :site_header_nav, :list, default: []
attr :site_footer_nav, :list, default: []
attr :site_social_links, :list, default: []
attr :site_announcement_text, :string, default: ""
attr :site_announcement_link, :string, default: ""
attr :site_announcement_style, :string, default: "info"
attr :site_footer_about, :string, default: ""
attr :site_footer_copyright, :string, default: ""
attr :site_footer_show_newsletter, :boolean, default: true
attr :event_prefix, :string, default: "site_"
def site_editor(assigns) do
# Build settings map for child components
settings = %{
announcement_text: assigns.site_announcement_text,
announcement_link: assigns.site_announcement_link,
announcement_style: assigns.site_announcement_style,
footer_about: assigns.site_footer_about,
footer_copyright: assigns.site_footer_copyright,
show_newsletter: assigns.site_footer_show_newsletter
}
assigns = assign(assigns, :settings, settings)
~H"""
<div class="editor-site-content">
<.site_section title="Branding" icon="hero-sparkles" open={true}>
<.branding_placeholder />
</.site_section>
<.site_section title="Announcement bar" icon="hero-megaphone">
<.announcement_editor settings={@settings} event_prefix={@event_prefix} />
</.site_section>
<.site_section title="Header navigation" icon="hero-bars-3">
<.nav_list_placeholder items={@site_header_nav} location="header" />
</.site_section>
<.site_section title="Footer" icon="hero-document-text">
<.footer_editor settings={@settings} event_prefix={@event_prefix} />
</.site_section>
<.site_section title="Footer navigation" icon="hero-queue-list">
<.nav_list_placeholder items={@site_footer_nav} location="footer" />
</.site_section>
<.site_section title="Social links" icon="hero-link">
<.social_links_editor links={@site_social_links} event_prefix={@event_prefix} />
</.site_section>
</div>
"""
end
# ── Collapsible Section ────────────────────────────────────────────
attr :title, :string, required: true
attr :icon, :string, default: nil
attr :open, :boolean, default: false
slot :inner_block, required: true
defp site_section(assigns) do
# Generate a stable ID from the title for the details element
id = "site-section-" <> (assigns.title |> String.downcase() |> String.replace(~r/\s+/, "-"))
assigns = assign(assigns, :id, id)
# Use phx-hook to preserve open state across re-renders
~H"""
<details id={@id} class="site-editor-section" phx-hook="DetailsPreserver" {if @open, do: [open: true], else: []}>
<summary class="site-editor-section-header">
<.icon :if={@icon} name={@icon} class="size-4" />
<span>{@title}</span>
<.icon name="hero-chevron-down-mini" class="size-4 site-editor-chevron" />
</summary>
<div class="site-editor-section-content">
{render_slot(@inner_block)}
</div>
</details>
"""
end
# ── Branding Section (placeholder) ─────────────────────────────────
defp branding_placeholder(assigns) do
~H"""
<div class="site-editor-placeholder">
<p class="admin-text-secondary">
Branding settings (shop name, logo, favicon) will be moved here from the Theme tab.
</p>
<p class="admin-help-text">Coming soon in the next phase.</p>
</div>
"""
end
# ── Announcement Bar Editor ─────────────────────────────────────────
attr :settings, :map, required: true
attr :event_prefix, :string, default: "site_"
defp announcement_editor(assigns) do
~H"""
<form class="site-editor-form" phx-change={@event_prefix <> "update"}>
<div class="theme-section">
<label class="theme-section-label" for="announcement-text">Announcement text</label>
<input
type="text"
id="announcement-text"
name="site[announcement_text]"
value={@settings.announcement_text}
class="admin-input"
placeholder="Free shipping on orders over £40"
phx-debounce="500"
/>
</div>
<div class="theme-section">
<label class="theme-section-label" for="announcement-link">Link URL (optional)</label>
<input
type="text"
id="announcement-link"
name="site[announcement_link]"
value={@settings.announcement_link}
class="admin-input"
placeholder="/delivery"
phx-debounce="500"
/>
</div>
<div class="theme-section">
<label class="theme-section-label">Style</label>
<div class="site-editor-radio-group">
<label class="admin-radio-label">
<input
type="radio"
name="site[announcement_style]"
value="info"
checked={@settings.announcement_style == "info"}
/>
<span>Info</span>
</label>
<label class="admin-radio-label">
<input
type="radio"
name="site[announcement_style]"
value="sale"
checked={@settings.announcement_style == "sale"}
/>
<span>Sale</span>
</label>
<label class="admin-radio-label">
<input
type="radio"
name="site[announcement_style]"
value="warning"
checked={@settings.announcement_style == "warning"}
/>
<span>Warning</span>
</label>
</div>
</div>
</form>
"""
end
# ── Footer Content Editor ───────────────────────────────────────────
attr :settings, :map, required: true
attr :event_prefix, :string, default: "site_"
defp footer_editor(assigns) do
~H"""
<form class="site-editor-form" phx-change={@event_prefix <> "update"}>
<div class="theme-section">
<label class="theme-section-label" for="footer-about">About text</label>
<textarea
id="footer-about"
name="site[footer_about]"
rows="3"
class="admin-input admin-textarea"
placeholder="A short blurb about your shop..."
phx-debounce="500"
>{@settings.footer_about}</textarea>
</div>
<div class="theme-section">
<label class="theme-section-label" for="footer-copyright">Copyright text</label>
<input
type="text"
id="footer-copyright"
name="site[footer_copyright]"
value={@settings.footer_copyright}
class="admin-input"
placeholder={"Leave blank for \"© #{Date.utc_today().year} Shop Name\""}
phx-debounce="500"
/>
</div>
<div class="theme-section">
<label class="admin-check-label">
<input
type="checkbox"
name="site[show_newsletter]"
value="true"
checked={@settings.show_newsletter}
class="admin-checkbox admin-checkbox-sm"
/>
<span class="theme-check-text">Show newsletter signup</span>
</label>
</div>
</form>
"""
end
# ── Navigation List (placeholder) ───────────────────────────────────
attr :items, :list, required: true
attr :location, :string, required: true
defp nav_list_placeholder(assigns) do
~H"""
<div class="site-editor-nav-list">
<ul class="site-editor-nav-items">
<li :for={item <- @items} class="site-editor-nav-item">
<span class="site-editor-nav-label">{item.label}</span>
<span class="site-editor-nav-url admin-text-tertiary">{item.url}</span>
</li>
</ul>
<p :if={@items == []} class="admin-text-tertiary">No navigation items</p>
<p class="admin-help-text">
Drag to reorder. Full editing coming in the next phase.
</p>
</div>
"""
end
# ── Social Links Editor ──────────────────────────────────────────────
@platform_groups Berrypod.Site.SocialLink.platform_groups()
attr :links, :list, required: true
attr :event_prefix, :string, default: "site_"
defp social_links_editor(assigns) do
assigns = assign(assigns, :platform_groups, @platform_groups)
~H"""
<div class="site-editor-social-list">
<ul :if={@links != []} class="site-editor-social-items">
<li
:for={{link, index} <- Enum.with_index(@links)}
class="site-editor-social-item"
data-link-id={link.id}
>
<form class="site-editor-social-item-content" phx-change={@event_prefix <> "update_social_link"} phx-value-id={link.id}>
<input
type="text"
name={"social_link[#{link.id}][url]"}
value={link.url}
class="admin-input admin-input-sm site-editor-social-url"
placeholder="Paste your link..."
phx-debounce="300"
/>
<select
name={"social_link[#{link.id}][platform]"}
class="admin-select admin-select-sm site-editor-social-platform"
aria-label="Platform"
>
<optgroup :for={{group_name, platforms} <- @platform_groups} label={group_name}>
<option
:for={platform <- platforms}
value={platform}
selected={link.platform == platform}
>
{platform_label(platform)}
</option>
</optgroup>
</select>
</form>
<div class="site-editor-social-item-actions">
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_social_link"}
phx-value-id={link.id}
phx-value-dir="up"
disabled={index == 0}
aria-label="Move up"
>
<.icon name="hero-chevron-up-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_social_link"}
phx-value-id={link.id}
phx-value-dir="down"
disabled={index == length(@links) - 1}
aria-label="Move down"
>
<.icon name="hero-chevron-down-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button admin-icon-button-danger"
phx-click={@event_prefix <> "remove_social_link"}
phx-value-id={link.id}
aria-label="Remove"
>
<.icon name="hero-x-mark-mini" class="size-4" />
</button>
</div>
</li>
</ul>
<p :if={@links == []} class="admin-text-tertiary admin-empty-message">
No social links yet
</p>
<button
type="button"
class="admin-button admin-button-sm admin-button-outline site-editor-add-button"
phx-click={@event_prefix <> "add_social_link"}
>
<.icon name="hero-plus-mini" class="size-4" />
<span>Add social link</span>
</button>
</div>
"""
end
# ── Helpers ─────────────────────────────────────────────────────────
# Social
defp platform_label("instagram"), do: "Instagram"
defp platform_label("threads"), do: "Threads"
defp platform_label("facebook"), do: "Facebook"
defp platform_label("twitter"), do: "Twitter / X"
defp platform_label("snapchat"), do: "Snapchat"
defp platform_label("linkedin"), do: "LinkedIn"
# Video & streaming
defp platform_label("youtube"), do: "YouTube"
defp platform_label("twitch"), do: "Twitch"
defp platform_label("vimeo"), do: "Vimeo"
defp platform_label("kick"), do: "Kick"
defp platform_label("rumble"), do: "Rumble"
# Music & podcasts
defp platform_label("spotify"), do: "Spotify"
defp platform_label("soundcloud"), do: "SoundCloud"
defp platform_label("bandcamp"), do: "Bandcamp"
defp platform_label("applepodcasts"), do: "Apple Podcasts"
# Creative
defp platform_label("pinterest"), do: "Pinterest"
defp platform_label("behance"), do: "Behance"
defp platform_label("dribbble"), do: "Dribbble"
defp platform_label("tumblr"), do: "Tumblr"
defp platform_label("medium"), do: "Medium"
# Support & sales
defp platform_label("patreon"), do: "Patreon"
defp platform_label("kofi"), do: "Ko-fi"
defp platform_label("etsy"), do: "Etsy"
defp platform_label("gumroad"), do: "Gumroad"
defp platform_label("substack"), do: "Substack"
# Federated
defp platform_label("mastodon"), do: "Mastodon"
defp platform_label("pixelfed"), do: "Pixelfed"
defp platform_label("bluesky"), do: "Bluesky"
defp platform_label("peertube"), do: "PeerTube"
defp platform_label("lemmy"), do: "Lemmy"
defp platform_label("matrix"), do: "Matrix"
# Developer
defp platform_label("github"), do: "GitHub"
defp platform_label("gitlab"), do: "GitLab"
defp platform_label("codeberg"), do: "Codeberg"
defp platform_label("sourcehut"), do: "SourceHut"
defp platform_label("reddit"), do: "Reddit"
# Messaging
defp platform_label("discord"), do: "Discord"
defp platform_label("telegram"), do: "Telegram"
defp platform_label("signal"), do: "Signal"
defp platform_label("whatsapp"), do: "WhatsApp"
# Other
defp platform_label("linktree"), do: "Linktree"
defp platform_label("rss"), do: "RSS feed"
defp platform_label("website"), do: "Website"
defp platform_label("custom"), do: "Custom link"
defp platform_label(other), do: String.capitalize(other)
end

View File

@ -66,7 +66,6 @@ defmodule BerrypodWeb.Admin.Backup do
end
end
def handle_event("validate_upload", _params, socket) do
{:noreply, socket}
end
@ -274,11 +273,10 @@ defmodule BerrypodWeb.Admin.Backup do
<% end %>
</div>
<p class="admin-section-desc">
{Backup.format_size(@stats.total_size)} total ·
{length(@stats.tables)} tables ·
{@stats.key_counts["products"] || 0} products ·
{@stats.key_counts["orders"] || 0} orders ·
{@stats.key_counts["images"] || 0} images
{Backup.format_size(@stats.total_size)} total · {length(@stats.tables)} tables · {@stats.key_counts[
"products"
] || 0} products · {@stats.key_counts["orders"] || 0} orders · {@stats.key_counts["images"] ||
0} images
</p>
<div class="admin-section-body">
<button
@ -459,10 +457,22 @@ defmodule BerrypodWeb.Admin.Backup do
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Current</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@stats.total_size)}</dd></div>
<div><dt>Products</dt><dd>{@stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@stats.key_counts["images"] || 0}</dd></div>
<div>
<dt>Size</dt>
<dd>{Backup.format_size(@stats.total_size)}</dd>
</div>
<div>
<dt>Products</dt>
<dd>{@stats.key_counts["products"] || 0}</dd>
</div>
<div>
<dt>Orders</dt>
<dd>{@stats.key_counts["orders"] || 0}</dd>
</div>
<div>
<dt>Images</dt>
<dd>{@stats.key_counts["images"] || 0}</dd>
</div>
</dl>
</div>
<div class="backup-comparison-arrow">
@ -471,10 +481,22 @@ defmodule BerrypodWeb.Admin.Backup do
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Uploaded</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd></div>
<div><dt>Products</dt><dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd></div>
<div>
<dt>Size</dt>
<dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd>
</div>
<div>
<dt>Products</dt>
<dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd>
</div>
<div>
<dt>Orders</dt>
<dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd>
</div>
<div>
<dt>Images</dt>
<dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd>
</div>
</dl>
</div>
</div>
@ -482,7 +504,9 @@ defmodule BerrypodWeb.Admin.Backup do
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
<div class="backup-validation backup-validation-ok">
<.icon name="hero-check-circle-mini" class="size-4" />
<span>Backup validated · Schema version {@uploaded_backup.stats.latest_migration}</span>
<span>
Backup validated · Schema version {@uploaded_backup.stats.latest_migration}
</span>
</div>
<%= if @restoring do %>
@ -496,22 +520,40 @@ defmodule BerrypodWeb.Admin.Backup do
<% else %>
<%= if @confirming_restore do %>
<div class="backup-warning">
<p>This will replace your current database. A backup will be saved automatically.</p>
<p>
This will replace your current database. A backup will be saved automatically.
</p>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-danger admin-btn-sm" phx-click="execute_restore">
<button
type="button"
class="admin-btn admin-btn-danger admin-btn-sm"
phx-click="execute_restore"
>
Replace database
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_restore"
>
Cancel
</button>
</div>
</div>
<% else %>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-primary admin-btn-sm" phx-click="confirm_restore">
<button
type="button"
class="admin-btn admin-btn-primary admin-btn-sm"
phx-click="confirm_restore"
>
Restore this backup
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_restore"
>
Cancel
</button>
</div>
@ -526,7 +568,11 @@ defmodule BerrypodWeb.Admin.Backup do
</span>
</div>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_restore"
>
Cancel
</button>
</div>

View File

@ -851,36 +851,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
# ── Helpers ──
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
site_name = Settings.site_name()
if site_name != "Store Name" do
"#{site_name} · #{user.email}"
else
user.email
end
end
defp account_summary(_), do: "Account created"
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
case Provider.get(type) do
nil -> "Connected"
info -> "Connected to #{info.name}"
end
end
defp provider_summary(_), do: nil
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
case Settings.secret_hint("stripe_api_key") do
nil -> "Connected"
hint -> "Connected · #{hint}"
end
end
defp stripe_summary(_), do: nil
defp provider_card_options(providers) do
Enum.map(providers, fn provider ->
option = %{

View File

@ -20,7 +20,7 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
alias Berrypod.{Media, Settings}
alias Berrypod.{Media, Settings, Site}
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
@ -53,6 +53,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_active_tab, :page)
# Theme editing state
|> assign(:theme_editing, false)
|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, nil)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_logo_image, nil)
@ -64,6 +66,21 @@ defmodule BerrypodWeb.PageEditorHook do
# Settings editing state
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :idle)
# Site editing state
|> assign(:site_editing, false)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, nil)
|> assign(:site_header_nav, [])
|> assign(:site_footer_nav, [])
|> assign(:site_social_links, [])
|> assign(:site_announcement_text, "")
|> assign(:site_announcement_link, "")
|> assign(:site_announcement_style, "info")
|> assign(:site_footer_about, "")
|> assign(:site_footer_copyright, "")
|> assign(:site_footer_show_newsletter, true)
# Navigation warning state
|> assign(:editor_nav_blocked, nil)
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
@ -119,6 +136,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_active_tab, :settings)
|> maybe_enter_theme_mode()
"site" ->
socket
|> assign(:editor_sheet_state, :open)
|> assign(:editor_active_tab, :site)
|> maybe_enter_site_mode()
nil ->
# No edit param - collapse the editor (supports browser back)
assign(socket, :editor_sheet_state, :collapsed)
@ -153,6 +176,14 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
defp maybe_enter_site_mode(socket) do
if socket.assigns.site_editing do
socket
else
load_site_state(socket)
end
end
# ── handle_info ─────────────────────────────────────────────────
defp handle_editor_info(:editor_clear_save_status, socket) do
@ -192,18 +223,6 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# Sync URL ?edit param with editor state
defp sync_edit_url_param(socket, :collapsed) do
path = socket.assigns.editor_current_path || "/"
push_patch(socket, to: path)
end
defp sync_edit_url_param(socket, :open) do
path = socket.assigns.editor_current_path || "/"
tab = socket.assigns.editor_active_tab
push_patch(socket, to: "#{path}?edit=#{tab}")
end
# Tab switching for unified editor
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
if socket.assigns.is_admin do
@ -249,6 +268,26 @@ defmodule BerrypodWeb.PageEditorHook do
end
assign(socket, :editor_active_tab, :settings)
:site ->
# Site tab shows site-wide content editors
# Load theme state for branding settings (will be moved here from theme tab)
socket =
if socket.assigns.theme_editing do
socket
else
load_theme_state(socket)
end
# Load site state if not already loaded
socket =
if socket.assigns.site_editing do
socket
else
load_site_state(socket)
end
assign(socket, :editor_active_tab, :site)
end
# Open the sheet and sync URL with new tab
@ -276,6 +315,54 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# Unified save works for all tabs regardless of editing mode
defp handle_editor_event("editor_save_all", _params, socket) do
if socket.assigns.is_admin do
socket = save_all_tabs(socket)
{:halt, socket}
else
{:cont, socket}
end
end
# Navigation blocked by unsaved changes
defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
{:halt, assign(socket, :editor_nav_blocked, href)}
end
defp handle_editor_event("editor_save_and_navigate", _params, socket) do
href = socket.assigns.editor_nav_blocked
socket = save_all_tabs(socket)
socket =
socket
|> assign(:editor_nav_blocked, nil)
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
{:halt, socket}
end
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
href = socket.assigns.editor_nav_blocked
socket = revert_all_tabs(socket)
socket =
socket
|> assign(:editor_nav_blocked, nil)
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
{:halt, socket}
end
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
socket =
socket
|> assign(:editor_nav_blocked, nil)
|> assign(:editor_sheet_state, :open)
{:halt, socket}
end
defp handle_editor_event("editor_" <> action, params, socket) do
if socket.assigns.editing do
handle_editor_action(action, params, socket)
@ -302,8 +389,29 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# Site editing events (announcement bar, footer, etc.)
defp handle_editor_event("site_" <> action, params, socket) do
if socket.assigns.is_admin do
handle_site_action(action, params, socket)
else
{:cont, socket}
end
end
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# Sync URL ?edit param with editor state
defp sync_edit_url_param(socket, :collapsed) do
path = socket.assigns.editor_current_path || "/"
push_patch(socket, to: path)
end
defp sync_edit_url_param(socket, :open) do
path = socket.assigns.editor_current_path || "/"
tab = socket.assigns.editor_active_tab
push_patch(socket, to: "#{path}?edit=#{tab}")
end
# ── Block manipulation actions ───────────────────────────────────
defp handle_editor_action("move_up", %{"id" => id}, socket) do
@ -603,6 +711,11 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
defp handle_editor_action("save_all", _params, socket) do
socket = save_all_tabs(socket)
{:halt, socket}
end
defp handle_editor_action("reset_defaults", _params, socket) do
slug = socket.assigns.page.slug
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
@ -627,8 +740,16 @@ defmodule BerrypodWeb.PageEditorHook do
defp handle_theme_action("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
# Get preset values and apply in-memory (don't persist yet)
case Presets.get(preset_atom) do
nil ->
{:halt, socket}
preset_values ->
# Merge preset values into current settings
current = socket.assigns.theme_editor_settings
theme_settings = struct(current, preset_values)
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
@ -641,14 +762,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, preset_atom)
|> assign(:theme_editor_contrast_warning, contrast_warning)
|> assign(:theme_dirty, true)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
{:error, _} ->
{:halt, socket}
end
end
@ -658,6 +777,8 @@ defmodule BerrypodWeb.PageEditorHook do
socket
)
when field in @standalone_settings do
# Standalone settings (site_name, site_description) save immediately for now
# TODO: Track these separately for proper revert
Settings.put_setting(field, value, "string")
# Also update the main assigns so ThemeHook sees the change
{:halt, assign(socket, String.to_existing_atom(field), value)}
@ -677,6 +798,8 @@ defmodule BerrypodWeb.PageEditorHook do
value = params[field]
if value do
# Standalone settings save immediately for now
# TODO: Track these separately for proper revert
Settings.put_setting(field, value, "string")
{:halt, assign(socket, String.to_existing_atom(field), value)}
else
@ -719,13 +842,14 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp handle_theme_action("remove_logo", _params, socket) do
# Delete the image immediately (this is a destructive action)
if logo = socket.assigns.theme_editor_logo_image do
Media.delete_image(logo)
end
{:ok, theme_settings} =
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | logo_image_id: nil, show_site_name: true}
generated_css = CSSGenerator.generate(theme_settings)
socket =
@ -733,38 +857,51 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:theme_editor_logo_image, nil)
|> assign(:logo_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_header", _params, socket) do
# Delete the image immediately (this is a destructive action)
if header = socket.assigns.theme_editor_header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | header_image_id: nil}
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_editor_header_image, nil)
|> assign(:header_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_icon", _params, socket) do
# Delete the image immediately (this is a destructive action)
if icon = socket.assigns.theme_editor_icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | icon_image_id: nil}
socket =
socket
|> assign(:theme_editor_icon_image, nil)
|> assign(:icon_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
{:halt, socket}
end
@ -853,6 +990,222 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown settings actions
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
# --- Site tab event handlers ---
defp handle_site_action("update", %{"site" => site_params}, socket) do
socket = handle_site_update(socket, site_params)
{:halt, socket}
end
defp handle_site_action("update", _params, socket), do: {:halt, socket}
# Social link CRUD operations (persist immediately like images)
defp handle_site_action("add_social_link", _params, socket) do
# Create with "custom" platform and blank URL
# User will paste their link, which auto-detects the platform
position = length(socket.assigns.site_social_links)
attrs = %{
platform: "custom",
url: "",
position: position
}
case Site.create_social_link(attrs) do
{:ok, link} ->
links = socket.assigns.site_social_links ++ [link]
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
end
defp handle_site_action("update_social_link", %{"id" => id} = params, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
if link do
# Extract the nested params from the form field name
link_params = params["social_link"][id] || %{}
target = params["_target"] || []
# Check what field was changed
url_changed? = List.last(target) == "url"
platform_changed? = List.last(target) == "platform"
# Build attrs based on what changed
attrs =
cond do
# URL changed - normalize and update URL, maybe auto-detect platform
url_changed? ->
url = link_params["url"] |> Berrypod.Site.SocialLink.normalize_url()
detected = Berrypod.Site.SocialLink.detect_platform(url)
base = %{url: url}
# Auto-detect platform when:
# 1. Current platform is "custom" (initial state), OR
# 2. New URL detects to a different platform than currently set
# (e.g., changing from github.com to twitter.com)
should_update_platform? =
detected &&
detected != "custom" &&
(link.platform == "custom" || detected != link.platform)
if should_update_platform? do
Map.put(base, :platform, detected)
else
base
end
# Platform explicitly changed by user - use their selection
platform_changed? ->
%{platform: link_params["platform"]}
# Fallback
true ->
%{}
|> maybe_put(:url, link_params["url"])
|> maybe_put(:platform, link_params["platform"])
end
if attrs != %{} do
case Site.update_social_link(link, attrs) do
{:ok, updated_link} ->
links =
Enum.map(socket.assigns.site_social_links, fn l ->
if l.id == id, do: updated_link, else: l
end)
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
else
{:halt, socket}
end
else
{:halt, socket}
end
end
defp handle_site_action("remove_social_link", %{"id" => id}, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
if link do
case Site.delete_social_link(link) do
{:ok, _} ->
links = Enum.reject(socket.assigns.site_social_links, &(&1.id == id))
{:halt, assign(socket, :site_social_links, links)}
{:error, _} ->
{:halt, socket}
end
else
{:halt, socket}
end
end
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
links = socket.assigns.site_social_links
index = Enum.find_index(links, &(&1.id == id))
new_index =
case dir do
"up" -> max(0, index - 1)
"down" -> min(length(links) - 1, index + 1)
_ -> index
end
if index != new_index do
# Swap the items
item = Enum.at(links, index)
other = Enum.at(links, new_index)
reordered =
links
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
# Persist the new order
ids = Enum.map(reordered, & &1.id)
Site.reorder_social_links(ids)
{:halt, assign(socket, :site_social_links, reordered)}
else
{:halt, socket}
end
end
# Catch-all for unknown site actions
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
defp handle_site_update(socket, params) do
# Handle announcement bar fields (preview only, no persistence)
socket =
if Map.has_key?(params, "announcement_text") or
Map.has_key?(params, "announcement_link") or
Map.has_key?(params, "announcement_style") do
text = params["announcement_text"] || socket.assigns.site_announcement_text
link = params["announcement_link"] || socket.assigns.site_announcement_link
style = params["announcement_style"] || socket.assigns.site_announcement_style
socket
|> assign(:site_announcement_text, text)
|> assign(:site_announcement_link, link)
|> assign(:site_announcement_style, style)
else
socket
end
# Handle footer fields (preview only, no persistence)
socket =
if Map.has_key?(params, "footer_about") or
Map.has_key?(params, "footer_copyright") or
Map.has_key?(params, "show_newsletter") do
about = params["footer_about"] || socket.assigns.site_footer_about
copyright = params["footer_copyright"] || socket.assigns.site_footer_copyright
# Checkbox sends value when checked, absent when unchecked
show_newsletter =
if Map.has_key?(params, "show_newsletter") do
params["show_newsletter"] == "true"
else
false
end
socket
|> assign(:site_footer_about, about)
|> assign(:site_footer_copyright, copyright)
|> assign(:site_footer_show_newsletter, show_newsletter)
else
socket
end
# Mark as dirty if values differ from original
socket
|> compute_site_dirty()
end
defp compute_site_dirty(socket) do
original = socket.assigns[:site_editor_original]
dirty =
if original do
socket.assigns.site_announcement_text != original.announcement_text or
socket.assigns.site_announcement_link != original.announcement_link or
socket.assigns.site_announcement_style != original.announcement_style or
socket.assigns.site_footer_about != original.footer_about or
socket.assigns.site_footer_copyright != original.footer_copyright or
socket.assigns.site_footer_show_newsletter != original.show_newsletter
else
false
end
assign(socket, :site_dirty, dirty)
end
# Check if settings have changed from current page values
defp has_settings_changed?(page, params) do
page.title != (params["title"] || "") or
@ -881,30 +1234,34 @@ defmodule BerrypodWeb.PageEditorHook do
assign(socket, :settings_form, form)
end
# Helper to update a theme setting and regenerate CSS
# Helper to update a theme setting in-memory (preview only, no persistence)
defp update_theme_setting(socket, attrs, field) do
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
current = socket.assigns.theme_editor_settings
active_preset = Presets.detect_preset(theme_settings)
# Merge attrs into current settings (convert string keys to atoms)
theme_settings =
Enum.reduce(attrs, current, fn {key, value}, acc ->
atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key
Map.put(acc, atom_key, value)
end)
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> maybe_recompute_contrast(field)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
{:halt, socket}
active_preset = Presets.detect_preset(theme_settings)
{:error, _} ->
{:halt, socket}
end
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> assign(:theme_dirty, true)
|> maybe_recompute_contrast(field)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp maybe_recompute_contrast(socket, field)
@ -1017,6 +1374,8 @@ defmodule BerrypodWeb.PageEditorHook do
socket
|> assign(:theme_editing, true)
|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, theme_settings)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> assign(:theme_editor_logo_image, logo_image)
@ -1054,4 +1413,189 @@ defmodule BerrypodWeb.PageEditorHook do
:ok
end
end
# ── Site editing helpers ───────────────────────────────────────────
defp load_site_state(socket) do
site_settings = Site.get_settings()
# Store original values for revert capability
original = %{
announcement_text: site_settings.announcement_text,
announcement_link: site_settings.announcement_link,
announcement_style: site_settings.announcement_style,
footer_about: site_settings.footer_about,
footer_copyright: site_settings.footer_copyright,
show_newsletter: site_settings.show_newsletter
}
socket
|> assign(:site_editing, true)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, original)
|> assign(:site_header_nav, Site.list_nav_items(:header))
|> assign(:site_footer_nav, Site.list_nav_items(:footer))
|> assign(:site_social_links, Site.list_social_links())
|> assign(:site_announcement_text, site_settings.announcement_text)
|> assign(:site_announcement_link, site_settings.announcement_link)
|> assign(:site_announcement_style, site_settings.announcement_style)
|> assign(:site_footer_about, site_settings.footer_about)
|> assign(:site_footer_copyright, site_settings.footer_copyright)
|> assign(:site_footer_show_newsletter, site_settings.show_newsletter)
|> assign(:editor_sheet_state, :open)
end
# ── Unified save helpers ───────────────────────────────────────────
defp save_all_tabs(socket) do
socket
|> maybe_save_page()
|> maybe_save_theme()
|> maybe_save_site()
|> assign(:editor_save_status, :saved)
|> schedule_save_status_clear()
end
defp maybe_save_page(socket) do
if socket.assigns[:editor_dirty] do
%{page: page, editing_blocks: blocks} = socket.assigns
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
{:ok, _saved_page} ->
updated_page = Pages.get_page(page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, updated_page.blocks)
socket
|> assign(:page, updated_page)
|> assign(:editing_blocks, updated_page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
{:error, _changeset} ->
socket
end
else
socket
end
end
defp maybe_save_theme(socket) do
if socket.assigns[:theme_dirty] do
case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) do
{:ok, theme_settings} ->
socket
|> assign(:theme_editor_original, theme_settings)
|> assign(:theme_dirty, false)
{:error, _} ->
socket
end
else
socket
end
end
defp maybe_save_site(socket) do
if socket.assigns[:site_dirty] do
Site.put_announcement(
socket.assigns.site_announcement_text,
socket.assigns.site_announcement_link,
socket.assigns.site_announcement_style
)
Site.put_footer_content(
socket.assigns.site_footer_about,
socket.assigns.site_footer_copyright,
socket.assigns.site_footer_show_newsletter
)
# Update original to match saved values
original = %{
announcement_text: socket.assigns.site_announcement_text,
announcement_link: socket.assigns.site_announcement_link,
announcement_style: socket.assigns.site_announcement_style,
footer_about: socket.assigns.site_footer_about,
footer_copyright: socket.assigns.site_footer_copyright,
show_newsletter: socket.assigns.site_footer_show_newsletter
}
socket
|> assign(:site_editor_original, original)
|> assign(:site_dirty, false)
else
socket
end
end
defp schedule_save_status_clear(socket) do
Process.send_after(self(), :editor_clear_save_status, 2500)
socket
end
# ── Revert helpers ─────────────────────────────────────────────────
defp revert_all_tabs(socket) do
socket
|> maybe_revert_page()
|> maybe_revert_theme()
|> maybe_revert_site()
end
defp maybe_revert_page(socket) do
if socket.assigns[:editor_dirty] do
# Reload the page from database
page = Pages.get_page(socket.assigns.page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
socket
|> assign(:page, page)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
else
socket
end
end
defp maybe_revert_theme(socket) do
if socket.assigns[:theme_dirty] do
original = socket.assigns.theme_editor_original
generated_css = CSSGenerator.generate(original, &BerrypodWeb.Endpoint.static_path/1)
socket
|> assign(:theme_editor_settings, original)
|> assign(:theme_settings, original)
|> assign(:generated_css, generated_css)
|> assign(:theme_dirty, false)
else
socket
end
end
defp maybe_revert_site(socket) do
if socket.assigns[:site_dirty] do
original = socket.assigns.site_editor_original
socket
|> assign(:site_announcement_text, original.announcement_text)
|> assign(:site_announcement_link, original.announcement_link)
|> assign(:site_announcement_style, original.announcement_style)
|> assign(:site_footer_about, original.footer_about)
|> assign(:site_footer_copyright, original.footer_copyright)
|> assign(:site_footer_show_newsletter, original.show_newsletter)
|> assign(:site_dirty, false)
else
socket
end
end
# ── Small helpers ───────────────────────────────────────────────────
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end

View File

@ -86,9 +86,12 @@ defmodule BerrypodWeb.PageRenderer do
editing={@editing}
theme_editing={Map.get(assigns, :theme_editing, false)}
editor_dirty={@editor_dirty}
theme_dirty={Map.get(assigns, :theme_dirty, false)}
site_dirty={Map.get(assigns, :site_dirty, false)}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status}
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
editor_nav_blocked={Map.get(assigns, :editor_nav_blocked)}
has_editable_page={@page != nil}
>
<.editor_panel_content
@ -123,6 +126,15 @@ defmodule BerrypodWeb.PageRenderer do
settings_form={Map.get(assigns, :settings_form)}
settings_dirty={Map.get(assigns, :settings_dirty, false)}
settings_save_status={Map.get(assigns, :settings_save_status, :idle)}
site_header_nav={Map.get(assigns, :site_header_nav, [])}
site_footer_nav={Map.get(assigns, :site_footer_nav, [])}
site_social_links={Map.get(assigns, :site_social_links, [])}
site_announcement_text={Map.get(assigns, :site_announcement_text, "")}
site_announcement_link={Map.get(assigns, :site_announcement_link, "")}
site_announcement_style={Map.get(assigns, :site_announcement_style, "info")}
site_footer_about={Map.get(assigns, :site_footer_about, "")}
site_footer_copyright={Map.get(assigns, :site_footer_copyright, "")}
site_footer_show_newsletter={Map.get(assigns, :site_footer_show_newsletter, true)}
/>
</.editor_sheet>
"""
@ -160,6 +172,15 @@ defmodule BerrypodWeb.PageRenderer do
attr :settings_form, :map, default: nil
attr :settings_dirty, :boolean, default: false
attr :settings_save_status, :atom, default: :idle
attr :site_header_nav, :list, default: []
attr :site_footer_nav, :list, default: []
attr :site_social_links, :list, default: []
attr :site_announcement_text, :string, default: ""
attr :site_announcement_link, :string, default: ""
attr :site_announcement_style, :string, default: "info"
attr :site_footer_about, :string, default: ""
attr :site_footer_copyright, :string, default: ""
attr :site_footer_show_newsletter, :boolean, default: true
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
~H"""
@ -200,6 +221,7 @@ defmodule BerrypodWeb.PageRenderer do
end
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
# Legacy settings tab - will be removed once page settings are merged into Page tab
~H"""
<BerrypodWeb.ShopComponents.SettingsEditor.settings_editor
page={@page}
@ -213,6 +235,22 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
defp editor_panel_content(%{editor_active_tab: :site} = assigns) do
~H"""
<BerrypodWeb.ShopComponents.SiteEditor.site_editor
site_header_nav={@site_header_nav}
site_footer_nav={@site_footer_nav}
site_social_links={@site_social_links}
site_announcement_text={@site_announcement_text}
site_announcement_link={@site_announcement_link}
site_announcement_style={@site_announcement_style}
site_footer_about={@site_footer_about}
site_footer_copyright={@site_footer_copyright}
site_footer_show_newsletter={@site_footer_show_newsletter}
/>
"""
end
# Theme editor content - uses shared component
attr :theme_editor_settings, :map, default: nil
attr :theme_editor_active_preset, :atom, default: nil
@ -467,7 +505,7 @@ defmodule BerrypodWeb.PageRenderer do
end
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
~H"<.social_links_card />"
~H"<.social_links_card links={assigns[:social_links] || []} />"
end
defp render_block(%{block: %{"type" => "info_card"}} = assigns) do

View File

@ -14,7 +14,7 @@ defmodule BerrypodWeb.ThemeHook do
import Phoenix.Component, only: [assign: 3]
alias Berrypod.{Products, Settings, Media}
alias Berrypod.{Products, Settings, Site, Media}
alias Berrypod.Theme.{CSSCache, CSSGenerator}
@default_header_nav [
@ -67,8 +67,12 @@ defmodule BerrypodWeb.ThemeHook do
:is_admin,
!!(socket.assigns[:current_scope] && socket.assigns.current_scope.user)
)
|> assign(:header_nav_items, load_nav("header_nav", @default_header_nav))
|> assign(:footer_nav_items, load_nav("footer_nav", @default_footer_nav))
|> assign(:header_nav_items, load_header_nav())
|> assign(:footer_nav_items, load_footer_nav())
|> assign(:social_links, Site.social_links_for_shop())
|> assign(:announcement_text, Site.announcement_text())
|> assign(:announcement_link, Site.announcement_link())
|> assign(:announcement_style, Site.announcement_style())
{:cont, socket}
end
@ -87,10 +91,24 @@ defmodule BerrypodWeb.ThemeHook do
end
end
defp load_nav(key, default) do
case Settings.get_setting(key) do
items when is_list(items) -> items
_ -> default
end
defp load_header_nav do
items = Site.nav_items_for_shop("header")
if items == [], do: @default_header_nav, else: add_active_slugs(items)
end
defp load_footer_nav do
items = Site.nav_items_for_shop("footer")
if items == [], do: @default_footer_nav, else: items
end
# Add active_slugs for Shop nav item to highlight on collection and pdp pages
defp add_active_slugs(items) do
Enum.map(items, fn item ->
if item["slug"] == "collection" do
Map.put(item, "active_slugs", ["collection", "pdp"])
else
item
end
end)
end
end

View File

@ -0,0 +1,16 @@
defmodule Berrypod.Repo.Migrations.CreateSocialLinks do
use Ecto.Migration
def change do
create table(:social_links, primary_key: false) do
add :id, :binary_id, primary_key: true
add :platform, :string, null: false
add :url, :string, null: false
add :position, :integer, null: false, default: 0
timestamps(type: :utc_datetime)
end
create index(:social_links, [:position])
end
end

View File

@ -0,0 +1,19 @@
defmodule Berrypod.Repo.Migrations.CreateNavItems do
use Ecto.Migration
def change do
create table(:nav_items, primary_key: false) do
add :id, :binary_id, primary_key: true
add :location, :string, null: false
add :label, :string, null: false
add :url, :string, null: false
add :page_id, references(:pages, type: :binary_id, on_delete: :nilify_all)
add :position, :integer, null: false, default: 0
timestamps(type: :utc_datetime)
end
create index(:nav_items, [:location, :position])
create index(:nav_items, [:page_id])
end
end

View File

@ -0,0 +1,43 @@
defmodule Berrypod.Repo.Migrations.AllowNullSocialLinkUrl do
use Ecto.Migration
def change do
# Allow blank URLs so users can add a link and then paste the URL
# SQLite doesn't support ALTER COLUMN, so we recreate the table
execute(
"CREATE TABLE social_links_new (
id BLOB PRIMARY KEY,
platform TEXT NOT NULL,
url TEXT,
position INTEGER NOT NULL DEFAULT 0,
inserted_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"DROP TABLE social_links_new"
)
execute(
"INSERT INTO social_links_new SELECT * FROM social_links",
"INSERT INTO social_links SELECT * FROM social_links_new"
)
execute(
"DROP TABLE social_links",
"CREATE TABLE social_links (
id BLOB PRIMARY KEY,
platform TEXT NOT NULL,
url TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
inserted_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)"
)
execute(
"ALTER TABLE social_links_new RENAME TO social_links",
"ALTER TABLE social_links RENAME TO social_links_new"
)
create index(:social_links, [:position])
end
end

View File

@ -10,7 +10,7 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Berrypod.Settings
alias Berrypod.{Settings, Site}
# Set default theme settings (Studio preset)
IO.puts("Setting up default theme settings...")
@ -18,3 +18,10 @@ IO.puts("Setting up default theme settings...")
{:ok, _theme} = Settings.apply_preset(:studio)
IO.puts("✓ Default theme settings applied (Studio preset)")
# Seed default navigation and social links
IO.puts("Setting up default site content...")
Site.seed_defaults()
IO.puts("✓ Default navigation and social links created")

233
test/berrypod/site_test.exs Normal file
View File

@ -0,0 +1,233 @@
defmodule Berrypod.SiteTest do
use Berrypod.DataCase, async: true
alias Berrypod.Site
alias Berrypod.Site.SocialLink
describe "social links CRUD" do
test "creates a social link" do
assert {:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test"})
assert link.platform == "instagram"
assert link.url == "https://instagram.com/test"
assert link.position == 0
end
test "lists social links ordered by position" do
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, _} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
links = Site.list_social_links()
assert length(links) == 3
assert Enum.map(links, & &1.platform) == ["instagram", "twitter", "github"]
end
test "updates a social link" do
{:ok, link} = Site.create_social_link(%{platform: "custom", url: ""})
{:ok, updated} = Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
assert updated.url == "https://example.com"
assert updated.platform == "website"
end
test "deletes a social link" do
{:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
assert {:ok, _} = Site.delete_social_link(link)
assert Site.list_social_links() == []
end
test "reorders social links" do
{:ok, a} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, b} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
{:ok, c} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
# Reorder: github, instagram, twitter
Site.reorder_social_links([c.id, a.id, b.id])
links = Site.list_social_links()
assert Enum.map(links, & &1.platform) == ["github", "instagram", "twitter"]
end
end
describe "social_links_for_shop/0" do
test "returns links formatted for shop components" do
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test", position: 0})
[link] = Site.social_links_for_shop()
assert link.platform == :instagram
assert link.url == "https://instagram.com/test"
assert link.label == "Instagram"
end
test "filters out links with empty URLs" do
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
{:ok, _} = Site.create_social_link(%{platform: "custom", url: "", position: 1})
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: nil, position: 2})
links = Site.social_links_for_shop()
assert length(links) == 1
assert hd(links).platform == :instagram
end
end
describe "SocialLink.normalize_url/1" do
test "trims whitespace" do
assert SocialLink.normalize_url(" https://example.com ") == "https://example.com"
end
test "adds https:// to bare domains" do
assert SocialLink.normalize_url("example.com") == "https://example.com"
assert SocialLink.normalize_url("github.com/user") == "https://github.com/user"
end
test "preserves http://" do
assert SocialLink.normalize_url("http://example.com") == "http://example.com"
end
test "preserves https://" do
assert SocialLink.normalize_url("https://example.com") == "https://example.com"
end
test "preserves app deep links" do
assert SocialLink.normalize_url("tg://resolve?domain=channel") == "tg://resolve?domain=channel"
assert SocialLink.normalize_url("spotify:track:123") == "spotify:track:123"
assert SocialLink.normalize_url("rss://feed.example.com") == "rss://feed.example.com"
end
test "preserves mailto: and tel:" do
assert SocialLink.normalize_url("mailto:user@example.com") == "mailto:user@example.com"
assert SocialLink.normalize_url("tel:+1234567890") == "tel:+1234567890"
end
test "handles nil and empty strings" do
assert SocialLink.normalize_url(nil) == nil
assert SocialLink.normalize_url("") == ""
assert SocialLink.normalize_url(" ") == ""
end
end
describe "SocialLink.detect_platform/1" do
test "detects common platforms from URLs" do
assert SocialLink.detect_platform("https://github.com/user") == "github"
assert SocialLink.detect_platform("https://twitter.com/user") == "twitter"
assert SocialLink.detect_platform("https://x.com/user") == "twitter"
assert SocialLink.detect_platform("https://instagram.com/user") == "instagram"
assert SocialLink.detect_platform("https://bsky.app/profile/user") == "bluesky"
assert SocialLink.detect_platform("https://mastodon.social/@user") == "mastodon"
end
test "handles www prefix" do
assert SocialLink.detect_platform("https://www.github.com/user") == "github"
assert SocialLink.detect_platform("https://www.youtube.com/watch?v=123") == "youtube"
end
test "detects subdomain-based platforms" do
assert SocialLink.detect_platform("https://artist.bandcamp.com") == "bandcamp"
assert SocialLink.detect_platform("https://writer.substack.com") == "substack"
assert SocialLink.detect_platform("https://user.tumblr.com") == "tumblr"
end
test "detects platforms from URI schemes" do
assert SocialLink.detect_platform("tg://resolve?domain=channel") == "telegram"
assert SocialLink.detect_platform("spotify:track:123") == "spotify"
assert SocialLink.detect_platform("rss://feed.example.com") == "rss"
assert SocialLink.detect_platform("discord://discord.com/channels/123") == "discord"
end
test "normalizes URLs before detection" do
assert SocialLink.detect_platform("github.com/user") == "github"
assert SocialLink.detect_platform(" twitter.com/user ") == "twitter"
end
test "returns custom for unknown domains" do
assert SocialLink.detect_platform("https://example.com") == "custom"
assert SocialLink.detect_platform("https://my-personal-site.org") == "custom"
end
test "returns nil for invalid input" do
assert SocialLink.detect_platform("") == nil
assert SocialLink.detect_platform(nil) == nil
end
end
describe "SocialLink changeset validation" do
test "requires platform" do
changeset = SocialLink.changeset(%SocialLink{}, %{url: "https://example.com"})
assert %{platform: ["can't be blank"]} = errors_on(changeset)
end
test "validates platform is in allowed list" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "invalid_platform"})
assert %{platform: ["is invalid"]} = errors_on(changeset)
end
test "allows empty URL" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: ""})
assert changeset.valid?
end
test "allows nil URL" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: nil})
assert changeset.valid?
end
test "validates URL has a scheme" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: "no-scheme.com"})
assert %{url: ["must be a valid URL"]} = errors_on(changeset)
end
test "accepts URLs with any scheme" do
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
assert changeset.valid?
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
assert changeset.valid?
end
end
describe "announcement settings" do
test "stores and retrieves announcement text" do
Site.set_announcement_text("Free shipping!")
assert Site.announcement_text() == "Free shipping!"
end
test "stores and retrieves announcement link" do
Site.set_announcement_link("/delivery")
assert Site.announcement_link() == "/delivery"
end
test "stores and retrieves announcement style" do
Site.set_announcement_style("sale")
assert Site.announcement_style() == "sale"
end
test "defaults to empty string for text and link" do
assert Site.announcement_text() == ""
assert Site.announcement_link() == ""
end
test "defaults to info for style" do
assert Site.announcement_style() == "info"
end
end
describe "footer settings" do
test "stores and retrieves footer about text" do
Site.set_footer_about("About us blurb")
assert Site.footer_about() == "About us blurb"
end
test "stores and retrieves footer copyright" do
Site.set_footer_copyright("© 2024 My Shop")
assert Site.footer_copyright() == "© 2024 My Shop"
end
test "stores and retrieves newsletter visibility" do
Site.set_show_newsletter(false)
assert Site.show_newsletter?() == false
Site.set_show_newsletter(true)
assert Site.show_newsletter?() == true
end
end
end

View File

@ -4,7 +4,7 @@ defmodule BerrypodWeb.Shop.NavigationTest do
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.{Pages, Settings}
alias Berrypod.{Pages, Settings, Site}
alias Berrypod.Pages.PageCache
setup do
@ -16,6 +16,9 @@ defmodule BerrypodWeb.Shop.NavigationTest do
describe "header navigation" do
test "renders default items", %{conn: conn} do
# Seed defaults if not already present
Site.seed_defaults()
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Home"
@ -24,15 +27,15 @@ defmodule BerrypodWeb.Shop.NavigationTest do
assert html =~ "Contact"
end
test "renders from saved settings", %{conn: conn} do
Settings.put_setting(
"header_nav",
[
%{"label" => "Blog", "href" => "/blog", "slug" => "blog"},
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
],
"json"
)
test "renders from database nav items", %{conn: conn} do
# Clear existing and add custom items
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
{:ok, _} =
Site.create_nav_item(%{location: "header", label: "Blog", url: "/blog", position: 0})
{:ok, _} =
Site.create_nav_item(%{location: "header", label: "FAQ", url: "/faq", position: 1})
{:ok, _view, html} = live(conn, ~p"/")
@ -44,6 +47,8 @@ defmodule BerrypodWeb.Shop.NavigationTest do
describe "footer navigation" do
test "renders default items", %{conn: conn} do
Site.seed_defaults()
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Delivery &amp; returns"
@ -51,15 +56,25 @@ defmodule BerrypodWeb.Shop.NavigationTest do
assert html =~ "Terms of service"
end
test "renders from saved settings", %{conn: conn} do
Settings.put_setting(
"footer_nav",
[
%{"label" => "Returns", "href" => "/returns", "slug" => "returns"},
%{"label" => "Shipping", "href" => "/shipping", "slug" => "shipping"}
],
"json"
)
test "renders from database nav items", %{conn: conn} do
# Clear existing and add custom items
for item <- Site.list_nav_items("footer"), do: Site.delete_nav_item(item)
{:ok, _} =
Site.create_nav_item(%{
location: "footer",
label: "Returns",
url: "/returns",
position: 0
})
{:ok, _} =
Site.create_nav_item(%{
location: "footer",
label: "Shipping",
url: "/shipping",
position: 1
})
{:ok, _view, html} = live(conn, ~p"/")
@ -71,16 +86,21 @@ defmodule BerrypodWeb.Shop.NavigationTest do
describe "custom page in navigation" do
test "renders when added to header nav", %{conn: conn} do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
{:ok, page} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
Settings.put_setting(
"header_nav",
[
%{"label" => "Home", "href" => "/", "slug" => "home"},
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
],
"json"
)
# Clear existing header nav and add custom items
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
{:ok, _} = Site.create_nav_item(%{location: "header", label: "Home", url: "/", position: 0})
{:ok, _} =
Site.create_nav_item(%{
location: "header",
label: "FAQ",
url: "/faq",
page_id: page.id,
position: 1
})
{:ok, _view, html} = live(conn, ~p"/")

View File

@ -171,7 +171,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|> render_click()
# Save
view |> element("button[phx-click='editor_save']") |> render_click()
view |> element("button[phx-click='editor_save_all']") |> render_click()
# Verify persistence
updated = Pages.get_page("home")
@ -277,7 +277,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
view |> element("button[phx-click='editor_save']") |> render_click()
view |> element("button[phx-click='editor_save_all']") |> render_click()
# After save, undo should be disabled
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")