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