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

View File

@@ -14,10 +14,21 @@ defmodule BerrypodWeb.Shop.Pages.Cart do
socket
|> assign(:page_title, "Cart")
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -50,6 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
|> assign(:page_title, "Order confirmed")
|> assign(:order, order)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
@@ -58,6 +59,16 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
{:redirect, redirect(socket, to: R.home())}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
alias Berrypod.{Pages, Pagination, Products}
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
@sort_options [
@@ -20,16 +21,29 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
def init(socket, _params, _uri) do
page = Pages.get_page("collection")
base = BerrypodWeb.Endpoint.url()
socket =
socket
|> assign(:page, page)
|> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured")
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
# When accessed via custom URL (e.g. /shop) without a collection slug, show all products
def handle_params(params, uri, socket) when not is_map_key(params, "slug") do
handle_params(Map.put(params, "slug", "all"), uri, socket)

View File

@@ -10,10 +10,12 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
alias BerrypodWeb.R
alias Berrypod.Orders.OrderNotifier
alias Berrypod.Pages
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.OrderLookupController
def init(socket, _params, _uri) do
page = Pages.get_page("contact")
base = BerrypodWeb.Endpoint.url()
socket =
socket
@@ -25,10 +27,22 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
|> assign(:og_url, R.url(R.contact()))
|> assign(:tracking_state, :idle)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
alias Berrypod.LegalPages
alias Berrypod.Pages
alias Berrypod.Theme.PreviewData
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
def init(socket, _params, _uri) do
@@ -27,10 +28,22 @@ defmodule BerrypodWeb.Shop.Pages.Content do
|> assign(seo)
|> assign(:page, page)
|> assign(:content_blocks, content_blocks)
|> assign(:json_ld, SeoHelpers.faq_json_ld(page && page.blocks))
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_event(_event, _params, _socket), do: :cont
# Returns {seo_assigns, content_blocks} for each content page

View File

@@ -6,6 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
alias BerrypodWeb.Helpers.SeoHelpers
def init(socket, _params, _uri) do
# Custom pages load in handle_params based on slug
@@ -55,10 +56,13 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
type: page.type,
published: page.published,
meta_description: page.meta_description,
meta_robots: page.meta_robots,
focus_keyword: page.focus_keyword,
url_slug: page.url_slug,
show_in_nav: page.show_in_nav,
nav_label: page.nav_label,
nav_position: page.nav_position
nav_position: page.nav_position,
og_image_id: page.og_image_id
}
end
@@ -91,6 +95,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
defp maybe_assign_meta(socket, page, base) do
socket
|> assign(:og_url, base <> "/#{page.slug}")
|> assign(:json_ld, SeoHelpers.faq_json_ld(page.blocks))
|> then(fn s ->
if page.meta_description do
assign(s, :page_description, page.meta_description)
@@ -98,5 +103,15 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
s
end
end)
|> then(fn s ->
meta_robots = page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(s, :meta_robots, meta_robots)
else
s
end
end)
|> SeoHelpers.assign_og_image(page, base)
end
end

View File

@@ -5,7 +5,8 @@ defmodule BerrypodWeb.Shop.Pages.Home do
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
alias Berrypod.{Pages, Settings}
alias BerrypodWeb.Helpers.SeoHelpers
def init(socket, _params, _uri) do
page = Pages.get_page("home")
@@ -14,28 +15,145 @@ defmodule BerrypodWeb.Shop.Pages.Home do
base = BerrypodWeb.Endpoint.url()
site_name = socket.assigns.site_name
org_ld =
Jason.encode!(
%{
"@context" => "https://schema.org",
"@type" => "Organization",
"name" => site_name,
"url" => base <> "/"
},
escape: :html_safe
)
org_ld = build_organization_json_ld(socket.assigns, base, site_name)
json_ld = combine_json_ld([org_ld, SeoHelpers.faq_json_ld(page.blocks)])
socket =
socket
|> assign(:page_title, "Home")
|> assign(:og_url, base <> "/")
|> assign(:json_ld, org_ld)
|> assign(:json_ld, json_ld)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
|> assign(extra)
{:noreply, socket}
end
# Combine multiple JSON-LD scripts into a single output (newline-separated)
defp combine_json_ld(ld_list) do
ld_list
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
[single] -> single
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
end
end
defp build_organization_json_ld(assigns, base_url, site_name) do
business_info = Settings.get_business_info()
org_type =
if business_info["business_type"] == "LocalBusiness",
do: "LocalBusiness",
else: "Organization"
org = %{
"@context" => "https://schema.org",
"@type" => org_type,
"name" => site_name,
"url" => base_url <> "/"
}
org
|> maybe_add_logo(assigns[:logo_image], base_url)
|> maybe_add_contact_point(business_info)
|> maybe_add_address(business_info)
|> maybe_add_same_as(assigns[:social_links])
|> Jason.encode!(escape: :html_safe)
end
defp maybe_add_logo(org, nil, _base_url), do: org
defp maybe_add_logo(org, logo_image, base_url) do
logo_url = base_url <> "/image_cache/#{logo_image.id}.webp"
Map.put(org, "logo", logo_url)
end
defp maybe_add_contact_point(org, business_info) do
phone = business_info["business_phone"]
email = business_info["business_email"]
cond do
present?(phone) and present?(email) ->
Map.put(org, "contactPoint", [
%{"@type" => "ContactPoint", "telephone" => phone, "contactType" => "customer service"},
%{"@type" => "ContactPoint", "email" => email, "contactType" => "customer service"}
])
present?(phone) ->
Map.put(org, "contactPoint", %{
"@type" => "ContactPoint",
"telephone" => phone,
"contactType" => "customer service"
})
present?(email) ->
Map.put(org, "contactPoint", %{
"@type" => "ContactPoint",
"email" => email,
"contactType" => "customer service"
})
true ->
org
end
end
defp maybe_add_address(org, business_info) do
street = business_info["address_street"]
city = business_info["address_city"]
country = business_info["address_country"]
if present?(street) or present?(city) or present?(country) do
address = %{"@type" => "PostalAddress"}
address =
address
|> maybe_put("streetAddress", street)
|> maybe_put("addressLocality", city)
|> maybe_put("addressRegion", business_info["address_region"])
|> maybe_put("postalCode", business_info["address_postal_code"])
|> maybe_put("addressCountry", country)
Map.put(org, "address", address)
else
org
end
end
defp maybe_add_same_as(org, nil), do: org
defp maybe_add_same_as(org, []), do: org
defp maybe_add_same_as(org, social_links) do
urls =
social_links
|> Enum.map(& &1.url)
|> Enum.filter(&present?/1)
if urls != [], do: Map.put(org, "sameAs", urls), else: org
end
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)
defp present?(nil), do: false
defp present?(""), do: false
defp present?(_), do: true
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -18,10 +18,21 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
socket
|> assign(:lookup_email, session["email_session"])
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(%{"order_number" => order_number}, _uri, socket) do
email = socket.assigns.lookup_email

View File

@@ -19,6 +19,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
|> assign(:page_title, "Your orders")
|> assign(:lookup_email, email)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
socket =
if email do
@@ -30,6 +31,16 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages, Reviews}
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
alias Berrypod.Images.Optimizer
alias Berrypod.Products
@@ -57,6 +58,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
page = Pages.get_page("pdp")
is_discontinued = product.status == "discontinued"
product_ld =
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
faq_ld = SeoHelpers.faq_json_ld(page.blocks)
combined_ld = combine_json_ld([product_ld, faq_ld])
socket =
socket
|> assign(:page_title, product.title)
@@ -64,10 +71,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:og_type, "product")
|> assign(:og_url, og_url)
|> assign(:og_image, og_image)
|> assign(
:json_ld,
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
)
|> assign(:json_ld, combined_ld)
|> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:quantity, 1)
@@ -78,6 +82,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:review_form, nil)
|> assign(:review_status, nil)
|> assign(:existing_review, nil)
|> maybe_assign_meta_robots(page)
# Check if user has an existing review for this product
socket = load_existing_review(socket)
@@ -488,6 +493,27 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
# Combine multiple JSON-LD scripts into a single output (newline-separated)
defp combine_json_ld(ld_list) do
ld_list
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
[single] -> single
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
end
end
defp format_review_for_display(review) do
%{
id: review.id,

View File

@@ -16,10 +16,21 @@ defmodule BerrypodWeb.Shop.Pages.Search do
socket
|> assign(:page_title, "Search")
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(params, _uri, socket) do
query = params["q"] || ""
results = if query != "", do: Search.search(query), else: []