Files
berrypod/lib/berrypod_web/components/seo_preview.ex
jamey 4aa7dece0c
All checks were successful
deploy / deploy (push) Successful in 4m59s
add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
- 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>
2026-04-17 16:47:43 +01:00

194 lines
5.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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