From 638bb4fb7088284c3471c7bcc08d85cd66362a64 Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 28 Mar 2026 10:09:33 +0000 Subject: [PATCH] add Site context with social links editor and site-wide settings - 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 --- PROGRESS.md | 22 +- assets/css/admin/components.css | 192 ++++++ assets/js/app.js | 91 ++- config/dev.exs | 1 - docs/plans/unified-editor-session.md | 321 +++++++++ lib/berrypod/backup.ex | 6 +- lib/berrypod/site.ex | 431 ++++++++++++ lib/berrypod/site/nav_item.ex | 32 + lib/berrypod/site/social_link.ex | 216 ++++++ .../components/shop_components/content.ex | 137 ++++ .../components/shop_components/layout.ex | 227 ++++++- .../components/shop_components/site_editor.ex | 420 ++++++++++++ lib/berrypod_web/live/admin/backup.ex | 88 ++- lib/berrypod_web/live/setup/onboarding.ex | 30 - lib/berrypod_web/page_editor_hook.ex | 628 ++++++++++++++++-- lib/berrypod_web/page_renderer.ex | 40 +- lib/berrypod_web/theme_hook.ex | 34 +- .../20260327163916_create_social_links.exs | 16 + .../20260327163919_create_nav_items.exs | 19 + ...60328091440_allow_null_social_link_url.exs | 43 ++ priv/repo/seeds.exs | 9 +- test/berrypod/site_test.exs | 233 +++++++ .../live/shop/navigation_test.exs | 76 ++- test/berrypod_web/page_editor_hook_test.exs | 4 +- 24 files changed, 3121 insertions(+), 195 deletions(-) create mode 100644 docs/plans/unified-editor-session.md create mode 100644 lib/berrypod/site.ex create mode 100644 lib/berrypod/site/nav_item.ex create mode 100644 lib/berrypod/site/social_link.ex create mode 100644 lib/berrypod_web/components/shop_components/site_editor.ex create mode 100644 priv/repo/migrations/20260327163916_create_social_links.exs create mode 100644 priv/repo/migrations/20260327163919_create_nav_items.exs create mode 100644 priv/repo/migrations/20260328091440_allow_null_social_link_url.exs create mode 100644 test/berrypod/site_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index 7f0c03a..cf6433d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index c2a6ec1..be6e87f 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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) ═══════════════════════════════════════════════════════════════════ */ diff --git a/assets/js/app.js b/assets/js/app.js index 97af535..4beb0ef 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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
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 diff --git a/config/dev.exs b/config/dev.exs index 8e6f96c..5c6e8b8 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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. # diff --git a/docs/plans/unified-editor-session.md b/docs/plans/unified-editor-session.md new file mode 100644 index 0000000..432cb3c --- /dev/null +++ b/docs/plans/unified-editor-session.md @@ -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 + +``` + +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 +
+``` + +### 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 + +
+

Unsaved changes

+

You have unsaved changes. What would you like to do?

+
+ + + +
+
+
+``` + +#### 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 diff --git a/lib/berrypod/backup.ex b/lib/berrypod/backup.ex index 37e9d22..7884746 100644 --- a/lib/berrypod/backup.ex +++ b/lib/berrypod/backup.ex @@ -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 = [ diff --git a/lib/berrypod/site.ex b/lib/berrypod/site.ex new file mode 100644 index 0000000..80f24e2 --- /dev/null +++ b/lib/berrypod/site.ex @@ -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 diff --git a/lib/berrypod/site/nav_item.ex b/lib/berrypod/site/nav_item.ex new file mode 100644 index 0000000..716b783 --- /dev/null +++ b/lib/berrypod/site/nav_item.ex @@ -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 diff --git a/lib/berrypod/site/social_link.ex b/lib/berrypod/site/social_link.ex new file mode 100644 index 0000000..4c4b61e --- /dev/null +++ b/lib/berrypod/site/social_link.ex @@ -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 diff --git a/lib/berrypod_web/components/shop_components/content.ex b/lib/berrypod_web/components/shop_components/content.ex index 3dd07db..b1f7a82 100644 --- a/lib/berrypod_web/components/shop_components/content.ex +++ b/lib/berrypod_web/components/shop_components/content.ex @@ -713,6 +713,143 @@ defmodule BerrypodWeb.ShopComponents.Content do """ end + # Additional social platforms + defp social_icon(%{platform: :linkedin} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :threads} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :whatsapp} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :twitch} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :spotify} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :soundcloud} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :vimeo} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :behance} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :dribbble} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :linktree} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :snapchat} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :reddit} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :medium} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :tumblr} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :applepodcasts} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :kick} = assigns) do + ~H""" + + + + """ + end + + defp social_icon(%{platform: :rumble} = assigns) do + ~H""" + + + + """ + end + # Fallback for unknown platforms defp social_icon(assigns) do ~H""" diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 14ce602..aa4927f 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -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""" -
-

{@message}

+
+ <%= if @link != "" do %> + +

{@display_message}

+
+ <% else %> +

{@display_message}

+ <% end %>
""" 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 """ 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 - <.social_links /> + <.social_links links={@social_links} />
@@ -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 /> {if @any_editing, do: "Show editor", else: "Edit"} - + <%!-- 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
{@title} - +
@@ -1201,6 +1338,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do } > Page +
@@ -1231,6 +1383,37 @@ defmodule BerrypodWeb.ShopComponents.Layout do <%!-- Live region for screen reader announcements --%>
+ + <%!-- Navigation warning modal --%> + +
+

Unsaved changes

+

You have unsaved changes that will be lost if you leave.

+
+ + + +
+
+
""" end diff --git a/lib/berrypod_web/components/shop_components/site_editor.ex b/lib/berrypod_web/components/shop_components/site_editor.ex new file mode 100644 index 0000000..820d07a --- /dev/null +++ b/lib/berrypod_web/components/shop_components/site_editor.ex @@ -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""" +
+ <.site_section title="Branding" icon="hero-sparkles" open={true}> + <.branding_placeholder /> + + + <.site_section title="Announcement bar" icon="hero-megaphone"> + <.announcement_editor settings={@settings} event_prefix={@event_prefix} /> + + + <.site_section title="Header navigation" icon="hero-bars-3"> + <.nav_list_placeholder items={@site_header_nav} location="header" /> + + + <.site_section title="Footer" icon="hero-document-text"> + <.footer_editor settings={@settings} event_prefix={@event_prefix} /> + + + <.site_section title="Footer navigation" icon="hero-queue-list"> + <.nav_list_placeholder items={@site_footer_nav} location="footer" /> + + + <.site_section title="Social links" icon="hero-link"> + <.social_links_editor links={@site_social_links} event_prefix={@event_prefix} /> + +
+ """ + 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""" +
+ + <.icon :if={@icon} name={@icon} class="size-4" /> + {@title} + <.icon name="hero-chevron-down-mini" class="size-4 site-editor-chevron" /> + +
+ {render_slot(@inner_block)} +
+
+ """ + end + + # ── Branding Section (placeholder) ───────────────────────────────── + + defp branding_placeholder(assigns) do + ~H""" +
+

+ Branding settings (shop name, logo, favicon) will be moved here from the Theme tab. +

+

Coming soon in the next phase.

+
+ """ + end + + # ── Announcement Bar Editor ───────────────────────────────────────── + + attr :settings, :map, required: true + attr :event_prefix, :string, default: "site_" + + defp announcement_editor(assigns) do + ~H""" +
"update"}> +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+
+
+ """ + end + + # ── Footer Content Editor ─────────────────────────────────────────── + + attr :settings, :map, required: true + attr :event_prefix, :string, default: "site_" + + defp footer_editor(assigns) do + ~H""" +
"update"}> +
+ + +
+ +
+ + +
+ +
+ +
+
+ """ + end + + # ── Navigation List (placeholder) ─────────────────────────────────── + + attr :items, :list, required: true + attr :location, :string, required: true + + defp nav_list_placeholder(assigns) do + ~H""" +
+
    +
  • + {item.label} + {item.url} +
  • +
+

No navigation items

+

+ Drag to reorder. Full editing coming in the next phase. +

+
+ """ + 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""" +
+
    +
  • +
    "update_social_link"} phx-value-id={link.id}> + + +
    +
    + + + +
    +
  • +
+

+ No social links yet +

+ +
+ """ + 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 diff --git a/lib/berrypod_web/live/admin/backup.ex b/lib/berrypod_web/live/admin/backup.ex index ce9e3cb..72848a6 100644 --- a/lib/berrypod_web/live/admin/backup.ex +++ b/lib/berrypod_web/live/admin/backup.ex @@ -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 %>

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

@@ -471,10 +481,22 @@ defmodule BerrypodWeb.Admin.Backup do

Uploaded

-
Size
{Backup.format_size(@uploaded_backup.stats.file_size)}
-
Products
{@uploaded_backup.stats.key_counts["products"] || 0}
-
Orders
{@uploaded_backup.stats.key_counts["orders"] || 0}
-
Images
{@uploaded_backup.stats.key_counts["images"] || 0}
+
+
Size
+
{Backup.format_size(@uploaded_backup.stats.file_size)}
+
+
+
Products
+
{@uploaded_backup.stats.key_counts["products"] || 0}
+
+
+
Orders
+
{@uploaded_backup.stats.key_counts["orders"] || 0}
+
+
+
Images
+
{@uploaded_backup.stats.key_counts["images"] || 0}
+
@@ -482,7 +504,9 @@ defmodule BerrypodWeb.Admin.Backup do <%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
<.icon name="hero-check-circle-mini" class="size-4" /> - Backup validated · Schema version {@uploaded_backup.stats.latest_migration} + + Backup validated · Schema version {@uploaded_backup.stats.latest_migration} +
<%= if @restoring do %> @@ -496,22 +520,40 @@ defmodule BerrypodWeb.Admin.Backup do <% else %> <%= if @confirming_restore do %>
-

This will replace your current database. A backup will be saved automatically.

+

+ This will replace your current database. A backup will be saved automatically. +

- -
<% else %>
- -
@@ -526,7 +568,11 @@ defmodule BerrypodWeb.Admin.Backup do
-
diff --git a/lib/berrypod_web/live/setup/onboarding.ex b/lib/berrypod_web/live/setup/onboarding.ex index 5075e0b..d053933 100644 --- a/lib/berrypod_web/live/setup/onboarding.ex +++ b/lib/berrypod_web/live/setup/onboarding.ex @@ -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 = %{ diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index f2b4fa1..65af237 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -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 diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index 08a7ee8..c90b328 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -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)} /> """ @@ -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""" + """ + 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 diff --git a/lib/berrypod_web/theme_hook.ex b/lib/berrypod_web/theme_hook.ex index 844680a..a90d0ac 100644 --- a/lib/berrypod_web/theme_hook.ex +++ b/lib/berrypod_web/theme_hook.ex @@ -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 diff --git a/priv/repo/migrations/20260327163916_create_social_links.exs b/priv/repo/migrations/20260327163916_create_social_links.exs new file mode 100644 index 0000000..c945d85 --- /dev/null +++ b/priv/repo/migrations/20260327163916_create_social_links.exs @@ -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 diff --git a/priv/repo/migrations/20260327163919_create_nav_items.exs b/priv/repo/migrations/20260327163919_create_nav_items.exs new file mode 100644 index 0000000..3c7f099 --- /dev/null +++ b/priv/repo/migrations/20260327163919_create_nav_items.exs @@ -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 diff --git a/priv/repo/migrations/20260328091440_allow_null_social_link_url.exs b/priv/repo/migrations/20260328091440_allow_null_social_link_url.exs new file mode 100644 index 0000000..4db8dca --- /dev/null +++ b/priv/repo/migrations/20260328091440_allow_null_social_link_url.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 105c5e2..85f2512 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -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") diff --git a/test/berrypod/site_test.exs b/test/berrypod/site_test.exs new file mode 100644 index 0000000..918b45d --- /dev/null +++ b/test/berrypod/site_test.exs @@ -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 diff --git a/test/berrypod_web/live/shop/navigation_test.exs b/test/berrypod_web/live/shop/navigation_test.exs index 97d029e..9b215fa 100644 --- a/test/berrypod_web/live/shop/navigation_test.exs +++ b/test/berrypod_web/live/shop/navigation_test.exs @@ -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 & 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"/") diff --git a/test/berrypod_web/page_editor_hook_test.exs b/test/berrypod_web/page_editor_hook_test.exs index 34b9b99..cbe669e 100644 --- a/test/berrypod_web/page_editor_hook_test.exs +++ b/test/berrypod_web/page_editor_hook_test.exs @@ -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]")