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>
426 lines
11 KiB
Elixir
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
|