Files
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

426 lines
11 KiB
Elixir

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