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
|
||||
|
||||
Reference in New Issue
Block a user