add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
All checks were successful
deploy / deploy (push) Successful in 4m59s
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:
@@ -11,6 +11,9 @@
|
||||
"Welcome to #{@site_name}"
|
||||
}
|
||||
/>
|
||||
<%= if assigns[:meta_robots] && assigns[:meta_robots] != "index, follow" do %>
|
||||
<meta name="robots" content={assigns[:meta_robots]} />
|
||||
<% end %>
|
||||
<!-- Favicon & PWA -->
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32x32.png" sizes="32x32" type="image/png" />
|
||||
|
||||
50
lib/berrypod_web/components/seo_checklist.ex
Normal file
50
lib/berrypod_web/components/seo_checklist.ex
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule BerrypodWeb.Components.SeoChecklist do
|
||||
@moduledoc """
|
||||
SEO checklist component showing focus keyword analysis results.
|
||||
|
||||
Displays a score and list of checks with pass/fail/warning status.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import BerrypodWeb.CoreComponents, only: [icon: 1]
|
||||
|
||||
alias Berrypod.SEO.Analyser
|
||||
|
||||
@doc """
|
||||
Renders the SEO checklist with score and checks.
|
||||
"""
|
||||
attr :page, :map, required: true
|
||||
|
||||
def seo_checklist(assigns) do
|
||||
checks = Analyser.analyse(assigns.page)
|
||||
score = Analyser.score(checks)
|
||||
level = Analyser.score_level(score)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:checks, checks)
|
||||
|> assign(:score, score)
|
||||
|> assign(:level, level)
|
||||
|
||||
~H"""
|
||||
<div class="seo-checklist">
|
||||
<div class="seo-score" data-level={@level}>
|
||||
<span class="seo-score-value">{@score}%</span>
|
||||
<span class="seo-score-label">SEO score</span>
|
||||
</div>
|
||||
<ul class="seo-checks">
|
||||
<li :for={check <- @checks} class="seo-check" data-status={check.status}>
|
||||
<.icon name={status_icon(check.status)} class="size-4 seo-check-icon" />
|
||||
<span class="seo-check-label">{check.label}</span>
|
||||
<span class="seo-check-hint">{check.message}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_icon(:pass), do: "hero-check-circle"
|
||||
defp status_icon(:fail), do: "hero-x-circle"
|
||||
defp status_icon(:warning), do: "hero-exclamation-triangle"
|
||||
end
|
||||
193
lib/berrypod_web/components/seo_preview.ex
Normal file
193
lib/berrypod_web/components/seo_preview.ex
Normal file
@@ -0,0 +1,193 @@
|
||||
defmodule BerrypodWeb.Components.SeoPreview do
|
||||
@moduledoc """
|
||||
SEO preview component showing how pages appear in search results and social cards.
|
||||
|
||||
Provides live-updating previews as users edit page titles and descriptions,
|
||||
with character count indicators showing optimal lengths.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders the full SEO preview panel with Google and social previews.
|
||||
"""
|
||||
attr :title, :string, required: true
|
||||
attr :description, :string, default: ""
|
||||
attr :url, :string, required: true
|
||||
attr :og_image, :string, default: nil
|
||||
attr :site_name, :string, required: true
|
||||
|
||||
def seo_preview(assigns) do
|
||||
~H"""
|
||||
<div class="seo-preview">
|
||||
<.google_preview title={@title} description={@description} url={@url} site_name={@site_name} />
|
||||
<.social_preview
|
||||
title={@title}
|
||||
description={@description}
|
||||
url={@url}
|
||||
image={@og_image}
|
||||
site_name={@site_name}
|
||||
/>
|
||||
<.character_counts title={@title} description={@description} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Google search result preview mockup.
|
||||
"""
|
||||
attr :title, :string, required: true
|
||||
attr :description, :string, default: ""
|
||||
attr :url, :string, required: true
|
||||
attr :site_name, :string, required: true
|
||||
|
||||
def google_preview(assigns) do
|
||||
# Truncate title at ~60 chars, description at ~160 chars
|
||||
truncated_title = truncate(assigns.title, 60)
|
||||
truncated_desc = truncate(assigns.description || "", 160)
|
||||
|
||||
# Build breadcrumb-style URL
|
||||
breadcrumb = build_breadcrumb(assigns.url, assigns.site_name)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:truncated_title, truncated_title)
|
||||
|> assign(:truncated_desc, truncated_desc)
|
||||
|> assign(:breadcrumb, breadcrumb)
|
||||
|
||||
~H"""
|
||||
<div class="seo-preview-section">
|
||||
<div class="seo-preview-label">Google search preview</div>
|
||||
<div class="seo-google-preview">
|
||||
<div class="seo-google-breadcrumb">{@breadcrumb}</div>
|
||||
<div class="seo-google-title">{@truncated_title}</div>
|
||||
<div class="seo-google-description">{@truncated_desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Social media card preview (Facebook/Twitter style).
|
||||
"""
|
||||
attr :title, :string, required: true
|
||||
attr :description, :string, default: ""
|
||||
attr :url, :string, required: true
|
||||
attr :image, :string, default: nil
|
||||
attr :site_name, :string, required: true
|
||||
|
||||
def social_preview(assigns) do
|
||||
truncated_title = truncate(assigns.title, 70)
|
||||
truncated_desc = truncate(assigns.description || "", 100)
|
||||
domain = extract_domain(assigns.url)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:truncated_title, truncated_title)
|
||||
|> assign(:truncated_desc, truncated_desc)
|
||||
|> assign(:domain, domain)
|
||||
|
||||
~H"""
|
||||
<div class="seo-preview-section">
|
||||
<div class="seo-preview-label">Social card preview</div>
|
||||
<div class="seo-social-preview">
|
||||
<div class="seo-social-image">
|
||||
<%= if @image do %>
|
||||
<img src={@image} alt="" />
|
||||
<% else %>
|
||||
<div class="seo-social-image-placeholder">
|
||||
<span>No image set</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="seo-social-content">
|
||||
<div class="seo-social-domain">{@domain}</div>
|
||||
<div class="seo-social-title">{@truncated_title}</div>
|
||||
<div class="seo-social-description">{@truncated_desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Character count indicators for title and description.
|
||||
"""
|
||||
attr :title, :string, required: true
|
||||
attr :description, :string, default: ""
|
||||
|
||||
def character_counts(assigns) do
|
||||
title_len = String.length(assigns.title || "")
|
||||
desc_len = String.length(assigns.description || "")
|
||||
|
||||
title_status = title_status(title_len)
|
||||
desc_status = desc_status(desc_len)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:title_len, title_len)
|
||||
|> assign(:desc_len, desc_len)
|
||||
|> assign(:title_status, title_status)
|
||||
|> assign(:desc_status, desc_status)
|
||||
|
||||
~H"""
|
||||
<div class="seo-char-counts">
|
||||
<div class="seo-char-count" data-status={@title_status}>
|
||||
<span class="seo-char-label">Title</span>
|
||||
<span class="seo-char-value">{@title_len}/60</span>
|
||||
<span class="seo-char-indicator"></span>
|
||||
</div>
|
||||
<div class="seo-char-count" data-status={@desc_status}>
|
||||
<span class="seo-char-label">Description</span>
|
||||
<span class="seo-char-value">{@desc_len}/160</span>
|
||||
<span class="seo-char-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Title: green ≤60, yellow 61-70, red >70
|
||||
defp title_status(len) when len <= 60, do: "good"
|
||||
defp title_status(len) when len <= 70, do: "warning"
|
||||
defp title_status(_), do: "error"
|
||||
|
||||
# Description: green 120-155, yellow 100-119 or 156-160, red <100 or >160
|
||||
defp desc_status(len) when len >= 120 and len <= 155, do: "good"
|
||||
defp desc_status(len) when len >= 100 and len <= 160, do: "warning"
|
||||
defp desc_status(_), do: "error"
|
||||
|
||||
defp truncate(nil, _max), do: ""
|
||||
defp truncate("", _max), do: ""
|
||||
|
||||
defp truncate(text, max) when byte_size(text) > max do
|
||||
String.slice(text, 0, max - 3) <> "..."
|
||||
end
|
||||
|
||||
defp truncate(text, _max), do: text
|
||||
|
||||
defp build_breadcrumb(url, site_name) do
|
||||
case URI.parse(url) do
|
||||
%URI{path: path} when is_binary(path) ->
|
||||
parts =
|
||||
path
|
||||
|> String.split("/", trim: true)
|
||||
|> Enum.take(2)
|
||||
|
||||
if parts == [] do
|
||||
site_name
|
||||
else
|
||||
site_name <> " › " <> Enum.join(parts, " › ")
|
||||
end
|
||||
|
||||
_ ->
|
||||
site_name
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_domain(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{host: host} when is_binary(host) -> host
|
||||
_ -> "example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,6 +64,7 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
||||
|> assign(:header_nav, state.header_nav)
|
||||
|> assign(:footer_nav, state.footer_nav)
|
||||
|> assign(:social_links, state.social_links)
|
||||
|> assign(:business_info, state.business_info)
|
||||
|
||||
~H"""
|
||||
<div class="editor-site-content">
|
||||
@@ -109,6 +110,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
||||
<.site_section title="Social links" icon="hero-link">
|
||||
<.social_links_editor links={@social_links} event_prefix={@event_prefix} />
|
||||
</.site_section>
|
||||
|
||||
<.site_section title="Business info" icon="hero-building-storefront">
|
||||
<.business_info_editor info={@business_info} event_prefix={@event_prefix} />
|
||||
</.site_section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -856,6 +861,144 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Business Info Editor ────────────────────────────────────────────
|
||||
|
||||
attr :info, :map, required: true
|
||||
attr :event_prefix, :string, default: "site_"
|
||||
|
||||
defp business_info_editor(assigns) do
|
||||
~H"""
|
||||
<form class="site-editor-form" phx-change={@event_prefix <> "update_business_info"}>
|
||||
<p class="admin-text-secondary admin-text-sm" style="margin-bottom: 1rem;">
|
||||
Used for rich snippets in search results and business schema data.
|
||||
</p>
|
||||
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Business type</label>
|
||||
<div class="site-editor-radio-group">
|
||||
<label class="admin-radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="business_info[business_type]"
|
||||
value="Organization"
|
||||
checked={@info["business_type"] != "LocalBusiness"}
|
||||
/>
|
||||
<span>Organisation</span>
|
||||
</label>
|
||||
<label class="admin-radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="business_info[business_type]"
|
||||
value="LocalBusiness"
|
||||
checked={@info["business_type"] == "LocalBusiness"}
|
||||
/>
|
||||
<span>Local business</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label" for="business-phone">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="business-phone"
|
||||
name="business_info[business_phone]"
|
||||
value={@info["business_phone"]}
|
||||
class="admin-input"
|
||||
placeholder="+44 7123 456789"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label" for="business-email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="business-email"
|
||||
name="business_info[business_email]"
|
||||
value={@info["business_email"]}
|
||||
class="admin-input"
|
||||
placeholder="hello@example.com"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Address fields shown for LocalBusiness --%>
|
||||
<div
|
||||
class="business-address-fields"
|
||||
style={if @info["business_type"] != "LocalBusiness", do: "display: none;"}
|
||||
>
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label" for="business-street">Street address</label>
|
||||
<input
|
||||
type="text"
|
||||
id="business-street"
|
||||
name="business_info[address_street]"
|
||||
value={@info["address_street"]}
|
||||
class="admin-input"
|
||||
placeholder="123 High Street"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label" for="business-city">City</label>
|
||||
<input
|
||||
type="text"
|
||||
id="business-city"
|
||||
name="business_info[address_city]"
|
||||
value={@info["address_city"]}
|
||||
class="admin-input"
|
||||
placeholder="London"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-row admin-row-sm">
|
||||
<div class="theme-section admin-fill">
|
||||
<label class="theme-section-label" for="business-region">County / region</label>
|
||||
<input
|
||||
type="text"
|
||||
id="business-region"
|
||||
name="business_info[address_region]"
|
||||
value={@info["address_region"]}
|
||||
class="admin-input"
|
||||
placeholder="Greater London"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-section" style="flex: 0 0 8rem;">
|
||||
<label class="theme-section-label" for="business-postcode">Postcode</label>
|
||||
<input
|
||||
type="text"
|
||||
id="business-postcode"
|
||||
name="business_info[address_postal_code]"
|
||||
value={@info["address_postal_code"]}
|
||||
class="admin-input"
|
||||
placeholder="SW1A 1AA"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label" for="business-country">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
id="business-country"
|
||||
name="business_info[address_country]"
|
||||
value={@info["address_country"]}
|
||||
class="admin-input"
|
||||
placeholder="United Kingdom"
|
||||
phx-debounce="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# Social
|
||||
|
||||
67
lib/berrypod_web/controllers/gsc_auth_controller.ex
Normal file
67
lib/berrypod_web/controllers/gsc_auth_controller.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BerrypodWeb.GSCAuthController do
|
||||
@moduledoc """
|
||||
Handles Google Search Console OAuth flow.
|
||||
"""
|
||||
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.GSC.{Cache, OAuth}
|
||||
|
||||
@doc """
|
||||
Initiates the OAuth flow by redirecting to Google's consent screen.
|
||||
"""
|
||||
def connect(conn, _params) do
|
||||
case OAuth.authorize_url() do
|
||||
{:ok, url} ->
|
||||
redirect(conn, external: url)
|
||||
|
||||
{:error, :missing_client_id} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Google Search Console is not configured. Set GSC_CLIENT_ID and GSC_CLIENT_SECRET environment variables."
|
||||
)
|
||||
|> redirect(to: ~p"/admin/gsc")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles the OAuth callback from Google.
|
||||
|
||||
Exchanges the authorization code for tokens and redirects to the dashboard.
|
||||
"""
|
||||
def callback(conn, %{"code" => code}) do
|
||||
case OAuth.exchange_code(code) do
|
||||
{:ok, _access_token} ->
|
||||
# Invalidate any stale cache when reconnecting
|
||||
Cache.invalidate()
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Connected to Google Search Console")
|
||||
|> redirect(to: ~p"/admin/gsc")
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Failed to connect: #{inspect(reason)}")
|
||||
|> redirect(to: ~p"/admin/gsc")
|
||||
end
|
||||
end
|
||||
|
||||
def callback(conn, %{"error" => error}) do
|
||||
conn
|
||||
|> put_flash(:error, "Authorization denied: #{error}")
|
||||
|> redirect(to: ~p"/admin/gsc")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disconnects from Google Search Console by clearing stored tokens.
|
||||
"""
|
||||
def disconnect(conn, _params) do
|
||||
OAuth.disconnect()
|
||||
Cache.invalidate()
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Disconnected from Google Search Console")
|
||||
|> redirect(to: ~p"/admin/gsc")
|
||||
end
|
||||
end
|
||||
@@ -26,48 +26,66 @@ defmodule BerrypodWeb.SeoController do
|
||||
|
||||
def sitemap(conn, _params) do
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
products = Products.list_visible_products()
|
||||
products = Products.list_products_for_sitemap()
|
||||
categories = Products.list_categories()
|
||||
|
||||
static_pages = [
|
||||
{R.home(), "daily", "1.0"},
|
||||
{R.collection("all"), "daily", "0.9"},
|
||||
{R.about(), "monthly", "0.5"},
|
||||
{R.contact(), "monthly", "0.5"},
|
||||
{R.delivery(), "monthly", "0.5"},
|
||||
{R.privacy(), "monthly", "0.3"},
|
||||
{R.terms(), "monthly", "0.3"}
|
||||
{R.home(), "daily", "1.0", []},
|
||||
{R.collection("all"), "daily", "0.9", []},
|
||||
{R.about(), "monthly", "0.5", []},
|
||||
{R.contact(), "monthly", "0.5", []},
|
||||
{R.delivery(), "monthly", "0.5", []},
|
||||
{R.privacy(), "monthly", "0.3", []},
|
||||
{R.terms(), "monthly", "0.3", []}
|
||||
]
|
||||
|
||||
category_pages =
|
||||
Enum.map(categories, fn cat ->
|
||||
{R.collection(cat.slug), "daily", "0.8"}
|
||||
{R.collection(cat.slug), "daily", "0.8", []}
|
||||
end)
|
||||
|
||||
product_pages =
|
||||
Enum.map(products, fn product ->
|
||||
{R.product(product.slug), "weekly", "0.9"}
|
||||
images = product_image_entries(product, base)
|
||||
{R.product(product.slug), "weekly", "0.9", images}
|
||||
end)
|
||||
|
||||
custom_pages =
|
||||
Pages.list_custom_pages()
|
||||
|> Enum.filter(& &1.published)
|
||||
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6"} end)
|
||||
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6", []} end)
|
||||
|
||||
all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages
|
||||
|
||||
entries =
|
||||
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority} ->
|
||||
" <url>\n" <>
|
||||
" <loc>#{base}#{path}</loc>\n" <>
|
||||
" <changefreq>#{changefreq}</changefreq>\n" <>
|
||||
" <priority>#{priority}</priority>\n" <>
|
||||
" </url>"
|
||||
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority, images} ->
|
||||
image_tags =
|
||||
Enum.map_join(images, "\n", fn img ->
|
||||
"""
|
||||
<image:image>
|
||||
<image:loc>#{xml_escape(img.url)}</image:loc>
|
||||
<image:title>#{xml_escape(img.title)}</image:title>
|
||||
</image:image>
|
||||
"""
|
||||
|> String.trim_trailing()
|
||||
end)
|
||||
|
||||
url_content =
|
||||
" <loc>#{xml_escape(base <> path)}</loc>\n" <>
|
||||
" <changefreq>#{changefreq}</changefreq>\n" <>
|
||||
" <priority>#{priority}</priority>"
|
||||
|
||||
if image_tags == "" do
|
||||
" <url>\n#{url_content}\n </url>"
|
||||
else
|
||||
" <url>\n#{url_content}\n#{image_tags}\n </url>"
|
||||
end
|
||||
end)
|
||||
|
||||
xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
#{entries}
|
||||
</urlset>
|
||||
"""
|
||||
@@ -76,4 +94,27 @@ defmodule BerrypodWeb.SeoController do
|
||||
|> put_resp_content_type("application/xml")
|
||||
|> send_resp(200, xml)
|
||||
end
|
||||
|
||||
defp product_image_entries(product, base_url) do
|
||||
product.images
|
||||
|> Enum.take(5)
|
||||
|> Enum.map(fn product_image ->
|
||||
image = product_image.image
|
||||
alt_text = product_image.alt_text || product.title
|
||||
url = "#{base_url}/image_cache/#{image.id}.webp"
|
||||
|
||||
%{url: url, title: alt_text}
|
||||
end)
|
||||
end
|
||||
|
||||
defp xml_escape(nil), do: ""
|
||||
|
||||
defp xml_escape(text) when is_binary(text) do
|
||||
text
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
end
|
||||
|
||||
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule BerrypodWeb.Helpers.SeoHelpers do
|
||||
@moduledoc """
|
||||
SEO-related helpers for generating structured data.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
alias Berrypod.Media
|
||||
|
||||
@doc """
|
||||
Generates FAQPage JSON-LD schema from page blocks.
|
||||
|
||||
Extracts FAQ items from any FAQ blocks on the page and builds a valid
|
||||
FAQPage schema. Returns nil if no FAQ blocks with valid content are found.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> faq_json_ld([%{"type" => "faq", "settings" => %{"items" => [...]}}])
|
||||
~s({"@context":"https://schema.org","@type":"FAQPage",...})
|
||||
|
||||
"""
|
||||
def faq_json_ld(blocks) when is_list(blocks) do
|
||||
questions =
|
||||
blocks
|
||||
|> Enum.filter(&(&1["type"] == "faq"))
|
||||
|> Enum.flat_map(fn block ->
|
||||
(block["settings"] || %{})["items"] || []
|
||||
end)
|
||||
|> Enum.filter(fn item ->
|
||||
question = String.trim(item["question"] || "")
|
||||
answer = String.trim(item["answer"] || "")
|
||||
question != "" and answer != ""
|
||||
end)
|
||||
|> Enum.map(fn item ->
|
||||
%{
|
||||
"@type" => "Question",
|
||||
"name" => item["question"],
|
||||
"acceptedAnswer" => %{
|
||||
"@type" => "Answer",
|
||||
"text" => item["answer"]
|
||||
}
|
||||
}
|
||||
end)
|
||||
|
||||
if questions != [] do
|
||||
%{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "FAQPage",
|
||||
"mainEntity" => questions
|
||||
}
|
||||
|> Jason.encode!(escape: :html_safe)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def faq_json_ld(_), do: nil
|
||||
|
||||
@doc """
|
||||
Assigns an og_image URL to the socket based on page-specific or default image.
|
||||
|
||||
Priority: page-specific image > site-wide default > none
|
||||
"""
|
||||
def assign_og_image(socket, page, base) do
|
||||
og_image_id = get_og_image_id(page)
|
||||
|
||||
og_image =
|
||||
cond do
|
||||
og_image_id ->
|
||||
Media.get_image(og_image_id)
|
||||
|
||||
true ->
|
||||
Media.get_default_og_image()
|
||||
end
|
||||
|
||||
if og_image do
|
||||
url = og_image_url(og_image, base)
|
||||
assign(socket, :og_image, url)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp get_og_image_id(nil), do: nil
|
||||
defp get_og_image_id(%{og_image_id: id}), do: id
|
||||
defp get_og_image_id(page) when is_map(page), do: page[:og_image_id]
|
||||
defp get_og_image_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Generates a full URL for an OG image, preferring 1200px width for social sharing.
|
||||
|
||||
Falls back to the largest available width if the image is smaller than 1200px.
|
||||
"""
|
||||
def og_image_url(image, base) do
|
||||
path =
|
||||
if image.is_svg do
|
||||
"/image_cache/#{image.id}.webp"
|
||||
else
|
||||
widths = Berrypod.Images.Optimizer.applicable_widths(image.source_width)
|
||||
# Prefer 1200px or larger, otherwise use the largest available
|
||||
width = Enum.find(widths, List.last(widths), &(&1 >= 1200))
|
||||
|
||||
"/image_cache/#{image.id}-#{width}.webp"
|
||||
end
|
||||
|
||||
base <> path
|
||||
end
|
||||
end
|
||||
425
lib/berrypod_web/live/admin/gsc.ex
Normal file
425
lib/berrypod_web/live/admin/gsc.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -1285,6 +1285,11 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
handle_theme_action("remove_header", %{}, socket)
|
||||
end
|
||||
|
||||
defp handle_site_action("update_business_info", %{"business_info" => info_params}, socket) do
|
||||
socket = handle_business_info_update(socket, info_params)
|
||||
{:halt, socket}
|
||||
end
|
||||
|
||||
# Catch-all for unknown site actions
|
||||
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
|
||||
|
||||
@@ -1334,6 +1339,20 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:site_dirty, SiteEditorState.dirty?(state))
|
||||
end
|
||||
|
||||
defp handle_business_info_update(socket, params) do
|
||||
state = socket.assigns.site_state
|
||||
|
||||
# Update each business info field
|
||||
state =
|
||||
Enum.reduce(params, state, fn {key, value}, acc ->
|
||||
SiteEditorState.put_business_info(acc, key, value)
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:site_state, state)
|
||||
|> assign(:site_dirty, SiteEditorState.dirty?(state))
|
||||
end
|
||||
|
||||
# Helper to update site state and compute dirty flag
|
||||
defp update_site_state(socket, update_fn) do
|
||||
state = socket.assigns.site_state
|
||||
|
||||
@@ -801,6 +801,39 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_block(%{block: %{"type" => "faq"}} = assigns) do
|
||||
settings = assigns.block["settings"] || %{}
|
||||
title = settings["title"] || ""
|
||||
|
||||
items =
|
||||
(settings["items"] || [])
|
||||
|> Enum.filter(fn item ->
|
||||
q = item["question"] || ""
|
||||
String.trim(q) != ""
|
||||
end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:faq_title, title)
|
||||
|> assign(:items, items)
|
||||
|
||||
~H"""
|
||||
<section class="faq-section page-container">
|
||||
<h2 :if={@faq_title != ""} class="faq-title">{@faq_title}</h2>
|
||||
<dl class="faq-list">
|
||||
<details :for={item <- @items} class="faq-item">
|
||||
<summary class="faq-question">{item["question"]}</summary>
|
||||
<div class="faq-answer">
|
||||
<p :for={para <- String.split(item["answer"] || "", ~r/\n{2,}/, trim: true)}>
|
||||
{para}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</dl>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
|
||||
~H"""
|
||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||
|
||||
@@ -152,6 +152,11 @@ defmodule BerrypodWeb.Router do
|
||||
post "/providers", ProvidersController, :create
|
||||
post "/providers/:id", ProvidersController, :update
|
||||
|
||||
# GSC OAuth routes
|
||||
get "/gsc/connect", GSCAuthController, :connect
|
||||
get "/gsc/callback", GSCAuthController, :callback
|
||||
delete "/gsc/disconnect", GSCAuthController, :disconnect
|
||||
|
||||
live_session :admin,
|
||||
layout: {BerrypodWeb.Layouts, :admin},
|
||||
on_mount: [
|
||||
@@ -182,6 +187,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
||||
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
||||
live "/redirects", Admin.Redirects, :index
|
||||
live "/gsc", Admin.GSC, :index
|
||||
end
|
||||
|
||||
# Theme editor redirects to on-site editing
|
||||
|
||||
@@ -26,7 +26,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
state = SiteEditorState.revert(state)
|
||||
"""
|
||||
|
||||
alias Berrypod.Site
|
||||
alias Berrypod.{Settings, Site}
|
||||
alias Berrypod.Site.{NavItem, SocialLink}
|
||||
|
||||
defstruct [
|
||||
@@ -37,6 +37,8 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
:footer_about,
|
||||
:footer_copyright,
|
||||
:show_newsletter,
|
||||
# Business info (stored in Settings as JSON)
|
||||
:business_info,
|
||||
# List fields (separate tables)
|
||||
:header_nav,
|
||||
:footer_nav,
|
||||
@@ -52,6 +54,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
footer_about: String.t(),
|
||||
footer_copyright: String.t(),
|
||||
show_newsletter: boolean(),
|
||||
business_info: map(),
|
||||
header_nav: [NavItem.t()],
|
||||
footer_nav: [NavItem.t()],
|
||||
social_links: [SocialLink.t()],
|
||||
@@ -67,6 +70,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
header_nav = Site.list_nav_items(:header)
|
||||
footer_nav = Site.list_nav_items(:footer)
|
||||
social_links = Site.list_social_links()
|
||||
business_info = Settings.get_business_info()
|
||||
|
||||
state = %__MODULE__{
|
||||
announcement_text: settings.announcement_text,
|
||||
@@ -75,6 +79,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
footer_about: settings.footer_about,
|
||||
footer_copyright: settings.footer_copyright,
|
||||
show_newsletter: settings.show_newsletter,
|
||||
business_info: business_info,
|
||||
header_nav: header_nav,
|
||||
footer_nav: footer_nav,
|
||||
social_links: social_links
|
||||
@@ -102,6 +107,14 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
Map.put(state, field, value)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Update a business info field. Returns updated state.
|
||||
"""
|
||||
@spec put_business_info(t(), String.t(), any()) :: t()
|
||||
def put_business_info(%__MODULE__{business_info: info} = state, key, value) do
|
||||
%{state | business_info: Map.put(info, key, value)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add a new social link. Returns updated state.
|
||||
"""
|
||||
@@ -255,6 +268,11 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
state.show_newsletter
|
||||
)
|
||||
|
||||
# Save business info if changed
|
||||
if state.business_info != state._original.business_info do
|
||||
Settings.put_business_info(state.business_info)
|
||||
end
|
||||
|
||||
# Sync nav items and social links
|
||||
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
|
||||
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
|
||||
@@ -278,6 +296,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
footer_about: original.footer_about,
|
||||
footer_copyright: original.footer_copyright,
|
||||
show_newsletter: original.show_newsletter,
|
||||
business_info: original.business_info,
|
||||
header_nav: original.header_nav,
|
||||
footer_nav: original.footer_nav,
|
||||
social_links: original.social_links
|
||||
@@ -301,6 +320,7 @@ defmodule BerrypodWeb.SiteEditorState do
|
||||
footer_about: state.footer_about,
|
||||
footer_copyright: state.footer_copyright,
|
||||
show_newsletter: state.show_newsletter,
|
||||
business_info: state.business_info,
|
||||
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
|
||||
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
|
||||
social_links: Enum.map(state.social_links, &social_link_snapshot/1)
|
||||
|
||||
Reference in New Issue
Block a user