add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
All checks were successful
deploy / deploy (push) Successful in 4m59s

- Per-page SEO controls: meta robots directives, focus keyword, OG image
- Site-wide default OG image in admin settings
- FAQ block type with FAQPage JSON-LD schema
- Enhanced Organization JSON-LD with business info, contact, address
- Image sitemap with product images
- SEO preview panel with Google/social card mockups
- SEO checklist with real-time scoring
- Business info section in site editor
- GSC integration scaffolding (OAuth, client, cache)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-04-17 16:47:43 +01:00
parent 9facfd926e
commit 4aa7dece0c
42 changed files with 3881 additions and 41 deletions

View File

@@ -7,6 +7,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.Components.SeoChecklist
import BerrypodWeb.Components.SeoPreview
@impl true
def mount(%{"slug" => slug}, _session, socket) do
@@ -503,19 +505,77 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end
end
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images()
{:noreply,
socket
|> assign(:settings_og_picker_open, true)
|> assign(:settings_og_picker_images, images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, :settings_og_picker_open, false)}
end
def handle_event("pick_og_image", %{"image-id" => image_id}, socket) do
image = Media.get_image(image_id)
# Update the form with the new og_image_id
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", image_id)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, image)
|> assign(:settings_og_picker_open, false)}
end
def handle_event("clear_og_image", _params, socket) do
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", nil)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, nil)}
end
defp assign_settings_form(socket, slug) do
if Page.system_slug?(slug) do
socket
|> assign(:show_settings, false)
|> assign(:page_struct, nil)
|> assign(:settings_form, nil)
|> assign(:settings_og_image, nil)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
else
page_struct = Pages.get_page_struct(slug)
og_image =
if page_struct.og_image_id, do: Media.get_image(page_struct.og_image_id), else: nil
socket
|> assign(:show_settings, false)
|> assign(:page_struct, page_struct)
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|> assign(:settings_og_image, og_image)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
end
end
@@ -631,6 +691,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
label="Meta description"
phx-no-feedback
/>
<.input
field={@settings_form[:meta_robots]}
type="select"
label="Search engine indexing"
options={meta_robots_options()}
/>
<.input
field={@settings_form[:focus_keyword]}
label="Focus keyword"
placeholder="e.g. handmade prints"
/>
<%!-- Social sharing image --%>
<div class="page-settings-og-image">
<label class="admin-label">Social sharing image</label>
<p class="admin-hint">Shown when this page is shared on social media</p>
<input
type="hidden"
name="page[og_image_id]"
value={@settings_og_image && @settings_og_image.id}
/>
<%= if @settings_og_image do %>
<div class="page-settings-og-preview">
<img
src={media_image_url(@settings_og_image, 400)}
alt="Social preview"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Select image
</button>
<% end %>
</div>
<%!-- SEO analysis section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO analysis
</summary>
<div class="page-settings-seo-content">
<.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} />
</div>
</details>
<%!-- SEO preview section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO preview
</summary>
<div class="page-settings-seo-content">
<.seo_preview
title={seo_preview_title(@settings_form, @site_name)}
description={seo_preview_description(@settings_form)}
url={seo_preview_url(@settings_form)}
og_image={seo_preview_og_image(@settings_og_image)}
site_name={@site_name}
/>
</div>
</details>
<div class="page-settings-row">
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
<.input
@@ -717,6 +859,52 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
search={@image_picker_search}
upload={@uploads.image_picker_upload}
/>
<%!-- OG image picker modal --%>
<.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} />
</div>
"""
end
defp og_image_picker(assigns) do
~H"""
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
<div class="admin-modal-header">
<h2>Select social sharing image</h2>
<button
type="button"
phx-click="hide_og_picker"
class="admin-modal-close"
aria-label="Close"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<p class="admin-modal-hint">
Recommended: 1200×630px for best results on social platforms
</p>
<div class="admin-modal-body">
<div class="image-picker-grid">
<button
:for={image <- @images}
type="button"
phx-click="pick_og_image"
phx-value-image-id={image.id}
class="image-picker-item"
>
<img
src={media_image_url(image, 200)}
alt={image.alt || ""}
loading="lazy"
/>
</button>
</div>
<%= if @images == [] do %>
<p class="image-picker-empty">No images in media library yet</p>
<% end %>
</div>
</div>
</div>
"""
end
@@ -864,4 +1052,62 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
defp default_nav_items("header"), do: Site.default_header_nav()
defp default_nav_items("footer"), do: Site.default_footer_nav()
defp meta_robots_options do
[
{"Index this page (default)", "index, follow"},
{"Don't index this page", "noindex, follow"},
{"Index but don't follow links", "index, nofollow"},
{"Don't index or follow links", "noindex, nofollow"}
]
end
# SEO preview helpers — extract values from the live form
defp seo_preview_title(form, site_name) do
title = Phoenix.HTML.Form.input_value(form, :title) || ""
if title == "", do: site_name, else: "#{title} · #{site_name}"
end
defp seo_preview_description(form) do
Phoenix.HTML.Form.input_value(form, :meta_description) || ""
end
defp seo_preview_url(form) do
slug = Phoenix.HTML.Form.input_value(form, :slug) || ""
base = BerrypodWeb.Endpoint.url()
if slug == "", do: base, else: "#{base}/#{slug}"
end
# Build page map for SEO analysis
defp seo_analysis_page(form, blocks) do
%{
focus_keyword: Phoenix.HTML.Form.input_value(form, :focus_keyword),
title: Phoenix.HTML.Form.input_value(form, :title),
meta_description: Phoenix.HTML.Form.input_value(form, :meta_description),
slug: Phoenix.HTML.Form.input_value(form, :slug),
blocks: blocks
}
end
defp seo_preview_og_image(nil), do: nil
defp seo_preview_og_image(image) do
media_image_url(image, 400)
end
# Generate a URL for a media library image at the given width
defp media_image_url(nil, _width), do: nil
defp media_image_url(image, width) do
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
applicable_width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> Enum.find(&(&1 >= width))
"/image_cache/#{image.id}-#{applicable_width || width}.webp"
end
end
end