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

@@ -0,0 +1,425 @@
defmodule BerrypodWeb.Admin.GSC do
@moduledoc """
Google Search Console dashboard.
Shows connection status, top queries, and top pages from GSC data.
"""
use BerrypodWeb, :live_view
alias Berrypod.GSC.{Cache, Client, OAuth}
alias Berrypod.Settings
@impl true
def mount(_params, _session, socket) do
# Demo mode: set GSC_DEMO=1 to see the dashboard with sample data
demo_mode = System.get_env("GSC_DEMO") == "1"
connected = demo_mode or OAuth.connected?()
site_url = if demo_mode, do: "https://example.com", else: Settings.get_setting("gsc_site_url")
socket =
socket
|> assign(:page_title, "Search Console")
|> assign(:connected, connected)
|> assign(:site_url, site_url)
|> assign(:sites, [])
|> assign(:loading, false)
|> assign(:error, nil)
|> assign(:data, nil)
|> assign(:demo_mode, demo_mode)
socket =
cond do
demo_mode -> assign(socket, :data, demo_data())
connected && site_url -> load_data(socket)
true -> socket
end
{:ok, socket}
end
defp demo_data do
%{
top_queries: [
%{
keys: %{"query" => "wildflower tote bag"},
clicks: 145,
impressions: 2340,
ctr: 6.2,
position: 3.2
},
%{
keys: %{"query" => "custom art prints"},
clicks: 98,
impressions: 1890,
ctr: 5.2,
position: 4.1
},
%{
keys: %{"query" => "botanical poster"},
clicks: 76,
impressions: 1456,
ctr: 5.2,
position: 5.8
},
%{
keys: %{"query" => "nature wall art"},
clicks: 54,
impressions: 980,
ctr: 5.5,
position: 7.2
},
%{
keys: %{"query" => "meadow illustration"},
clicks: 32,
impressions: 654,
ctr: 4.9,
position: 8.4
}
],
top_pages: [
%{
keys: %{"page" => "https://example.com/products/wildflower-tote"},
clicks: 234,
impressions: 4500,
ctr: 5.2,
position: 4.1
},
%{
keys: %{"page" => "https://example.com/collections/art-prints"},
clicks: 187,
impressions: 3200,
ctr: 5.8,
position: 3.8
},
%{
keys: %{"page" => "https://example.com/"},
clicks: 156,
impressions: 2800,
ctr: 5.6,
position: 2.1
},
%{
keys: %{"page" => "https://example.com/products/botanical-poster"},
clicks: 98,
impressions: 1900,
ctr: 5.2,
position: 5.4
},
%{
keys: %{"page" => "https://example.com/about"},
clicks: 45,
impressions: 890,
ctr: 5.1,
position: 6.2
}
],
updated_at: DateTime.utc_now()
}
end
@impl true
def handle_event("select_site", %{"site_url" => site_url}, socket) do
Settings.put_setting("gsc_site_url", site_url, "string")
Cache.invalidate()
socket =
socket
|> assign(:site_url, site_url)
|> load_data()
{:noreply, socket}
end
def handle_event("refresh_data", _params, socket) do
Cache.invalidate()
{:noreply, load_data(socket)}
end
def handle_event("load_sites", _params, socket) do
case Client.list_sites() do
{:ok, sites} ->
{:noreply, assign(socket, :sites, sites)}
{:error, reason} ->
{:noreply, assign(socket, :error, "Failed to load sites: #{inspect(reason)}")}
end
end
defp load_data(socket) do
site_url = socket.assigns.site_url
case Cache.get_all() do
{:ok, data} ->
assign(socket, :data, data)
:miss ->
fetch_fresh_data(socket, site_url)
end
end
defp fetch_fresh_data(socket, site_url) do
with {:ok, queries} <- Client.top_queries(site_url, row_limit: 25),
{:ok, pages} <- Client.top_pages(site_url, row_limit: 25) do
Cache.put_top_queries(queries)
Cache.put_top_pages(pages)
data = %{
top_queries: queries,
top_pages: pages,
updated_at: DateTime.utc_now()
}
assign(socket, :data, data)
else
{:error, :not_connected} ->
assign(socket, :connected, false)
{:error, reason} ->
assign(socket, :error, "Failed to fetch data: #{inspect(reason)}")
end
end
@impl true
def render(assigns) do
~H"""
<.header>Search Console</.header>
<p class="admin-page-description">
See how your site performs in Google search results
</p>
<div class="gsc-dashboard">
<%= if not @connected do %>
<.connection_card configured={gsc_configured?()} />
<% else %>
<.site_selector
sites={@sites}
site_url={@site_url}
loading={@loading}
/>
<%= if @site_url do %>
<%= if @data do %>
<.data_header updated_at={@data.updated_at} />
<.metrics_grid data={@data} />
<% else %>
<.loading_state error={@error} />
<% end %>
<% else %>
<.no_site_selected />
<% end %>
<% end %>
</div>
"""
end
# Connection card for when not connected
defp connection_card(assigns) do
~H"""
<div class="gsc-card gsc-card-connect">
<div class="gsc-card-icon">
<.icon name="hero-magnifying-glass" class="size-8" />
</div>
<h2>Connect Google Search Console</h2>
<p>
See your search performance data, top queries, and page rankings
directly in your admin dashboard.
</p>
<%= if @configured do %>
<a href={~p"/admin/gsc/connect"} class="admin-btn admin-btn-primary">
Connect with Google
</a>
<% else %>
<p class="gsc-not-configured">
Set <code>GSC_CLIENT_ID</code>
and <code>GSC_CLIENT_SECRET</code>
environment variables to enable this feature.
</p>
<% end %>
</div>
"""
end
# Site selector dropdown
defp site_selector(assigns) do
~H"""
<div class="gsc-site-selector">
<div class="gsc-site-row">
<%= if @sites == [] do %>
<button phx-click="load_sites" class="admin-btn admin-btn-outline">
Load available sites
</button>
<% else %>
<form phx-change="select_site" class="gsc-site-form">
<label for="site_url">Site</label>
<select name="site_url" id="site_url" class="admin-select">
<option value="">Select a site...</option>
<%= for site <- @sites do %>
<option value={site["siteUrl"]} selected={@site_url == site["siteUrl"]}>
{site["siteUrl"]}
</option>
<% end %>
</select>
</form>
<% end %>
<a
href={~p"/admin/gsc/disconnect"}
data-method="delete"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Disconnect
</a>
</div>
</div>
"""
end
# Data header with refresh button and last updated
defp data_header(assigns) do
~H"""
<div class="gsc-data-header">
<span class="gsc-updated">
Last updated: {format_datetime(@updated_at)}
</span>
<button phx-click="refresh_data" class="admin-btn admin-btn-ghost admin-btn-sm">
<.icon name="hero-arrow-path" class="size-4" /> Refresh
</button>
</div>
"""
end
# Metrics grid with queries and pages tables
defp metrics_grid(assigns) do
~H"""
<div class="gsc-grid">
<div class="gsc-card">
<h3>Top queries</h3>
<p class="gsc-card-description">What people search for to find your site</p>
<.queries_table queries={@data.top_queries} />
</div>
<div class="gsc-card">
<h3>Top pages</h3>
<p class="gsc-card-description">Your best performing pages in search</p>
<.pages_table pages={@data.top_pages} />
</div>
</div>
"""
end
# Top queries table
defp queries_table(assigns) do
~H"""
<table class="gsc-table">
<thead>
<tr>
<th>Query</th>
<th class="gsc-th-num">Clicks</th>
<th class="gsc-th-num">Impr.</th>
<th class="gsc-th-num">CTR</th>
<th class="gsc-th-num">Pos.</th>
</tr>
</thead>
<tbody>
<%= if @queries == [] do %>
<tr>
<td colspan="5" class="gsc-empty">No data yet</td>
</tr>
<% else %>
<%= for row <- @queries do %>
<tr>
<td class="gsc-query">{row.keys["query"]}</td>
<td class="gsc-num">{row.clicks}</td>
<td class="gsc-num">{format_number(row.impressions)}</td>
<td class="gsc-num">{row.ctr}%</td>
<td class="gsc-num">{row.position}</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
"""
end
# Top pages table
defp pages_table(assigns) do
~H"""
<table class="gsc-table">
<thead>
<tr>
<th>Page</th>
<th class="gsc-th-num">Clicks</th>
<th class="gsc-th-num">Impr.</th>
<th class="gsc-th-num">CTR</th>
<th class="gsc-th-num">Pos.</th>
</tr>
</thead>
<tbody>
<%= if @pages == [] do %>
<tr>
<td colspan="5" class="gsc-empty">No data yet</td>
</tr>
<% else %>
<%= for row <- @pages do %>
<tr>
<td class="gsc-page">{format_page_url(row.keys["page"])}</td>
<td class="gsc-num">{row.clicks}</td>
<td class="gsc-num">{format_number(row.impressions)}</td>
<td class="gsc-num">{row.ctr}%</td>
<td class="gsc-num">{row.position}</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
"""
end
defp loading_state(assigns) do
~H"""
<div class="gsc-loading">
<%= if @error do %>
<p class="gsc-error">{@error}</p>
<% else %>
<p>Loading data...</p>
<% end %>
</div>
"""
end
defp no_site_selected(assigns) do
~H"""
<div class="gsc-no-site">
<p>Select a site to view its search performance data.</p>
</div>
"""
end
# Helper functions
defp gsc_configured? do
System.get_env("GSC_CLIENT_ID") != nil
end
defp format_datetime(nil), do: "Never"
defp format_datetime(dt) do
Calendar.strftime(dt, "%-d %b %Y, %H:%M")
end
defp format_number(n) when n >= 1000 do
"#{Float.round(n / 1000, 1)}k"
end
defp format_number(n), do: to_string(n)
defp format_page_url(url) when is_binary(url) do
case URI.parse(url) do
%{path: path} when is_binary(path) and path != "" -> path
_ -> url
end
end
defp format_page_url(url), do: url
end

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

View File

@@ -1,6 +1,7 @@
defmodule BerrypodWeb.Admin.Settings do
use BerrypodWeb, :live_view
alias Berrypod.Media
alias Berrypod.Products
alias Berrypod.Settings
alias Berrypod.Stripe.Setup, as: StripeSetup
@@ -19,7 +20,17 @@ defmodule BerrypodWeb.Admin.Settings do
|> assign(:signing_secret_status, :idle)
|> assign_stripe_state()
|> assign_products_state()
|> assign_url_prefixes()}
|> assign_url_prefixes()
|> assign_og_image_state()}
end
defp assign_og_image_state(socket) do
og_image = Media.get_default_og_image()
socket
|> assign(:og_image, og_image)
|> assign(:og_picker_open, false)
|> assign(:og_picker_images, [])
end
defp assign_url_prefixes(socket) do
@@ -170,6 +181,44 @@ defmodule BerrypodWeb.Admin.Settings do
end
end
# -- Events: OG image --
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images() |> Enum.take(50)
{:noreply, assign(socket, og_picker_open: true, og_picker_images: images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, og_picker_open: false)}
end
def handle_event("pick_og_image", %{"id" => id}, socket) do
image = Media.get_image(id)
if image do
Media.update_image_type(image, "default_og")
{:noreply,
socket
|> assign(:og_image, image)
|> assign(:og_picker_open, false)
|> put_flash(:info, "Default social image set")}
else
{:noreply, put_flash(socket, :error, "Image not found")}
end
end
def handle_event("clear_og_image", _params, socket) do
if socket.assigns.og_image do
Media.update_image_type(socket.assigns.og_image, "media")
end
{:noreply,
socket
|> assign(:og_image, nil)
|> put_flash(:info, "Default social image removed")}
end
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@@ -502,10 +551,113 @@ defmodule BerrypodWeb.Admin.Settings do
</form>
</div>
</section>
<%!-- Default social image --%>
<section class="admin-section">
<h2 class="admin-section-title">Default social image</h2>
<p class="admin-section-desc">
The image shown when pages are shared on social media.
Individual pages can override this in their settings.
</p>
<div class="admin-section-body">
<%= if @og_image do %>
<div class="page-settings-og-preview">
<img
src={og_image_url(@og_image)}
alt="Current social image"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-link-danger"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon name="hero-photo" class="size-4" /> Choose image
</button>
<% end %>
</div>
</section>
<.og_picker_modal
:if={@og_picker_open}
images={@og_picker_images}
/>
</div>
"""
end
defp og_picker_modal(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">
<h3>Choose social image</h3>
<button type="button" phx-click="hide_og_picker" class="admin-modal-close">
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<div class="admin-modal-body">
<p class="admin-help-text" style="margin-bottom: 1rem;">
Choose an image from your media library.
Recommended size: 1200×630 pixels.
</p>
<%= if @images == [] do %>
<p class="admin-text-secondary">
No images in your media library.
<.link navigate={~p"/admin/media"} class="admin-link">Upload images</.link>
first.
</p>
<% else %>
<div class="og-picker-grid">
<%= for image <- @images do %>
<button
type="button"
phx-click="pick_og_image"
phx-value-id={image.id}
class="og-picker-item"
>
<img src={og_image_url(image)} alt={image.filename} />
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
defp og_image_url(image) 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 >= 400))
"/image_cache/#{image.id}-#{applicable_width || 400}.webp"
end
end
# -- Function components --
attr :color, :string, required: true