add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
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:
jamey
2026-04-17 16:47:43 +01:00
parent 9facfd926e
commit 4aa7dece0c
42 changed files with 3881 additions and 41 deletions

View File

@@ -46,7 +46,9 @@ defmodule Berrypod.Application do
# Page definition cache - loads page block lists into ETS
Berrypod.Pages.PageCache,
# URL routes cache - custom slugs and prefixes
BerrypodWeb.R
BerrypodWeb.R,
# Google Search Console data cache
Berrypod.GSC.Cache
]
# See https://hexdocs.pm/elixir/Supervisor.html

119
lib/berrypod/gsc/cache.ex Normal file
View File

@@ -0,0 +1,119 @@
defmodule Berrypod.GSC.Cache do
@moduledoc """
ETS-backed cache for Google Search Console data.
Caches query and page data fetched from GSC to avoid rate limits
and provide fast access for the dashboard.
"""
use GenServer
@table_name :gsc_cache
@default_ttl :timer.hours(6)
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Gets cached top queries data.
Returns `{:ok, data, updated_at}` if found and not expired, `:miss` otherwise.
"""
def get_top_queries do
get(:top_queries)
end
@doc """
Gets cached top pages data.
Returns `{:ok, data, updated_at}` if found and not expired, `:miss` otherwise.
"""
def get_top_pages do
get(:top_pages)
end
@doc """
Caches top queries data.
"""
def put_top_queries(data) do
put(:top_queries, data)
end
@doc """
Caches top pages data.
"""
def put_top_pages(data) do
put(:top_pages, data)
end
@doc """
Returns all cached data if not expired.
Returns `{:ok, %{top_queries: [...], top_pages: [...], updated_at: DateTime.t()}}` or `:miss`.
"""
def get_all do
with {:ok, queries, updated_at} <- get_top_queries(),
{:ok, pages, _} <- get_top_pages() do
{:ok, %{top_queries: queries, top_pages: pages, updated_at: updated_at}}
else
_ -> :miss
end
end
@doc """
Invalidates all cached data.
"""
def invalidate do
GenServer.call(__MODULE__, :invalidate)
end
@doc """
Returns the timestamp of the last cache update.
"""
def last_updated do
case :ets.lookup(@table_name, :top_queries) do
[{:top_queries, _data, updated_at, _expires_at}] -> updated_at
_ -> nil
end
end
# Private helpers
defp get(key) do
case :ets.lookup(@table_name, key) do
[{^key, data, updated_at, expires_at}] ->
if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do
{:ok, data, updated_at}
else
:miss
end
_ ->
:miss
end
end
defp put(key, data) do
now = DateTime.utc_now()
expires_at = DateTime.add(now, @default_ttl, :millisecond)
:ets.insert(@table_name, {key, data, now, expires_at})
:ok
end
# GenServer callbacks
@impl true
def init(_opts) do
table = :ets.new(@table_name, [:set, :named_table, :public, read_concurrency: true])
{:ok, %{table: table}}
end
@impl true
def handle_call(:invalidate, _from, state) do
:ets.delete_all_objects(@table_name)
{:reply, :ok, state}
end
end

121
lib/berrypod/gsc/client.ex Normal file
View File

@@ -0,0 +1,121 @@
defmodule Berrypod.GSC.Client do
@moduledoc """
Google Search Console API client.
Provides functions to query search analytics data from GSC.
"""
alias Berrypod.GSC.OAuth
@api_base "https://searchconsole.googleapis.com/webmasters/v3"
@doc """
Lists all sites the authenticated user has access to.
Returns `{:ok, sites}` where sites is a list of maps with site_url keys.
"""
def list_sites do
with {:ok, token} <- OAuth.get_valid_token() do
case Req.get("#{@api_base}/sites", headers: auth_headers(token)) do
{:ok, %{status: 200, body: %{"siteEntry" => sites}}} ->
{:ok, Enum.map(sites, &Map.take(&1, ["siteUrl", "permissionLevel"]))}
{:ok, %{status: 200, body: _}} ->
{:ok, []}
{:ok, %{status: status, body: body}} ->
{:error, {:api_error, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Queries search analytics for the given site URL.
## Options
- `:start_date` - Start date (default: 28 days ago)
- `:end_date` - End date (default: yesterday)
- `:dimensions` - List of dimensions (default: ["query"])
- `:row_limit` - Max rows to return (default: 25)
Returns `{:ok, rows}` where each row contains metrics for the dimension values.
"""
def query_search_analytics(site_url, opts \\ []) do
with {:ok, token} <- OAuth.get_valid_token() do
end_date = opts[:end_date] || Date.add(Date.utc_today(), -1)
start_date = opts[:start_date] || Date.add(end_date, -27)
dimensions = opts[:dimensions] || ["query"]
row_limit = opts[:row_limit] || 25
body = %{
startDate: Date.to_iso8601(start_date),
endDate: Date.to_iso8601(end_date),
dimensions: dimensions,
rowLimit: row_limit
}
encoded_site = URI.encode(site_url, &URI.char_unreserved?/1)
url = "#{@api_base}/sites/#{encoded_site}/searchAnalytics/query"
case Req.post(url, json: body, headers: auth_headers(token)) do
{:ok, %{status: 200, body: %{"rows" => rows}}} ->
{:ok, format_rows(rows, dimensions)}
{:ok, %{status: 200, body: _}} ->
{:ok, []}
{:ok, %{status: status, body: body}} ->
{:error, {:api_error, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Fetches top queries for the site.
Returns the top queries by clicks with impressions, CTR, and position.
"""
def top_queries(site_url, opts \\ []) do
query_search_analytics(site_url, Keyword.merge(opts, dimensions: ["query"]))
end
@doc """
Fetches top pages for the site.
Returns the top pages by clicks with impressions, CTR, and position.
"""
def top_pages(site_url, opts \\ []) do
query_search_analytics(site_url, Keyword.merge(opts, dimensions: ["page"]))
end
# Private helpers
defp auth_headers(token) do
[{"authorization", "Bearer #{token}"}]
end
defp format_rows(rows, dimensions) do
Enum.map(rows, fn row ->
keys = row["keys"] || []
dimension_values =
dimensions
|> Enum.zip(keys)
|> Map.new()
%{
keys: dimension_values,
clicks: row["clicks"] || 0,
impressions: row["impressions"] || 0,
ctr: Float.round((row["ctr"] || 0) * 100, 2),
position: Float.round(row["position"] || 0, 1)
}
end)
end
end

186
lib/berrypod/gsc/oauth.ex Normal file
View File

@@ -0,0 +1,186 @@
defmodule Berrypod.GSC.OAuth do
@moduledoc """
Google Search Console OAuth2 authentication.
Handles the OAuth flow for connecting to Google Search Console,
including token exchange and refresh.
## Configuration
Requires environment variables:
- GSC_CLIENT_ID: Google OAuth client ID
- GSC_CLIENT_SECRET: Google OAuth client secret
Tokens are stored encrypted in the Settings context:
- gsc_access_token
- gsc_refresh_token
- gsc_token_expires_at
"""
alias Berrypod.Settings
@token_url "https://oauth2.googleapis.com/token"
@auth_url "https://accounts.google.com/o/oauth2/v2/auth"
@scope "https://www.googleapis.com/auth/webmasters.readonly"
@doc """
Returns the OAuth authorization URL for initiating the connection flow.
"""
def authorize_url do
case client_id() do
nil ->
{:error, :missing_client_id}
client_id ->
params = %{
client_id: client_id,
redirect_uri: redirect_uri(),
response_type: "code",
scope: @scope,
access_type: "offline",
prompt: "consent"
}
{:ok, @auth_url <> "?" <> URI.encode_query(params)}
end
end
@doc """
Exchanges an authorization code for access and refresh tokens.
Stores the tokens encrypted in Settings on success.
"""
def exchange_code(code) do
body = %{
code: code,
client_id: client_id(),
client_secret: client_secret(),
redirect_uri: redirect_uri(),
grant_type: "authorization_code"
}
case Req.post(@token_url, form: body) do
{:ok, %{status: 200, body: %{"access_token" => access_token} = response}} ->
store_tokens(access_token, response["refresh_token"], response["expires_in"])
{:ok, access_token}
{:ok, %{status: status, body: body}} ->
{:error, {:token_exchange_failed, status, body}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Refreshes the access token using the stored refresh token.
"""
def refresh_token do
case Settings.get_secret("gsc_refresh_token") do
nil ->
{:error, :no_refresh_token}
refresh_token ->
body = %{
refresh_token: refresh_token,
client_id: client_id(),
client_secret: client_secret(),
grant_type: "refresh_token"
}
case Req.post(@token_url, form: body) do
{:ok, %{status: 200, body: %{"access_token" => access_token} = response}} ->
# Refresh tokens may be rotated, so store the new one if provided
new_refresh = response["refresh_token"] || refresh_token
store_tokens(access_token, new_refresh, response["expires_in"])
{:ok, access_token}
{:ok, %{status: status, body: body}} ->
{:error, {:refresh_failed, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Returns a valid access token, refreshing if necessary.
Returns `{:ok, access_token}` or `{:error, reason}`.
"""
def get_valid_token do
case Settings.get_secret("gsc_access_token") do
nil ->
{:error, :not_connected}
access_token ->
if token_expired?() do
refresh_token()
else
{:ok, access_token}
end
end
end
@doc """
Returns whether GSC is connected (has stored tokens).
"""
def connected? do
Settings.has_secret?("gsc_refresh_token")
end
@doc """
Disconnects from GSC by clearing stored tokens.
"""
def disconnect do
Settings.delete_setting("gsc_access_token")
Settings.delete_setting("gsc_refresh_token")
Settings.delete_setting("gsc_token_expires_at")
:ok
end
# Private helpers
defp store_tokens(access_token, refresh_token, expires_in) do
Settings.put_secret("gsc_access_token", access_token)
if refresh_token do
Settings.put_secret("gsc_refresh_token", refresh_token)
end
if expires_in do
# Store expiry as Unix timestamp
expires_at = System.system_time(:second) + expires_in
Settings.put_setting("gsc_token_expires_at", expires_at, "integer")
end
end
defp token_expired? do
case Settings.get_setting("gsc_token_expires_at") do
nil ->
# If no expiry stored, assume it might be expired
true
expires_at when is_integer(expires_at) ->
# Refresh 5 minutes before actual expiry
System.system_time(:second) > expires_at - 300
_ ->
true
end
end
defp client_id do
System.get_env("GSC_CLIENT_ID")
end
defp client_secret do
System.get_env("GSC_CLIENT_SECRET")
end
defp redirect_uri do
base_url = BerrypodWeb.Endpoint.url()
"#{base_url}/admin/gsc/callback"
end
end

View File

@@ -167,6 +167,8 @@ defmodule Berrypod.Media do
nil
"""
def get_image(nil), do: nil
def get_image(id) do
Repo.get(ImageSchema, id)
end
@@ -252,6 +254,39 @@ defmodule Berrypod.Media do
end
end
@doc """
Gets the default OG (Open Graph) image for social sharing.
This is used as a fallback when pages don't have their own OG image set.
## Examples
iex> get_default_og_image()
%Image{}
"""
def get_default_og_image do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:default_og) do
{:ok, image} ->
image
:miss ->
image =
Repo.one(
from i in ImageSchema,
where: i.image_type == "default_og",
order_by: [desc: i.inserted_at],
limit: 1,
select: struct(i, [:id, :image_type, :source_width])
)
SettingsCache.put_cached(:default_og, image)
image
end
end
@doc """
Deletes an image.
@@ -361,6 +396,26 @@ defmodule Berrypod.Media do
|> Repo.update()
end
@doc "Updates the image_type of an existing image."
def update_image_type(%ImageSchema{} = image, new_type) do
old_type = image.image_type
result =
image
|> Ecto.Changeset.change(image_type: new_type)
|> Repo.update()
case result do
{:ok, updated} ->
invalidate_media_cache(old_type)
invalidate_media_cache(new_type)
{:ok, updated}
error ->
error
end
end
@doc """
Returns a list of places an image is referenced.
@@ -533,5 +588,8 @@ defmodule Berrypod.Media do
defp invalidate_media_cache("header"),
do: Berrypod.Settings.SettingsCache.invalidate_cached(:header)
defp invalidate_media_cache("default_og"),
do: Berrypod.Settings.SettingsCache.invalidate_cached(:default_og)
defp invalidate_media_cache(_), do: :ok
end

View File

@@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do
:dominant_colors
])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|> validate_inclusion(:image_type, ~w(logo header product icon media review))
|> validate_inclusion(:image_type, ~w(logo header product icon media review default_og))
|> validate_number(:file_size, less_than: @max_file_size)
|> detect_svg()
end

View File

@@ -292,6 +292,9 @@ defmodule Berrypod.Pages do
type: page.type || "system",
published: page.published,
meta_description: page.meta_description,
meta_robots: page.meta_robots,
focus_keyword: page.focus_keyword,
og_image_id: page.og_image_id,
show_in_nav: page.show_in_nav,
nav_label: page.nav_label,
nav_position: page.nav_position,

View File

@@ -241,6 +241,30 @@ defmodule Berrypod.Pages.BlockTypes do
}
]
},
"faq" => %{
name: "FAQ section",
description: "Frequently asked questions with expandable answers",
icon: "hero-question-mark-circle",
allowed_on: :all,
settings_schema: [
%SettingsField{
key: "title",
label: "Section title",
type: :text,
default: "Frequently asked questions"
},
%SettingsField{
key: "items",
label: "Questions",
type: :repeater,
default: [],
item_schema: [
%SettingsField{key: "question", label: "Question", type: :text, default: ""},
%SettingsField{key: "answer", label: "Answer", type: :textarea, default: ""}
]
}
]
},
# ── PDP blocks ──────────────────────────────────────────────────

View File

@@ -18,6 +18,13 @@ defmodule Berrypod.Pages.Page do
sitemap.xml robots.txt setup dev
)
@meta_robots_options [
"index, follow",
"noindex, follow",
"index, nofollow",
"noindex, nofollow"
]
schema "pages" do
field :slug, :string
field :title, :string
@@ -25,10 +32,13 @@ defmodule Berrypod.Pages.Page do
field :type, :string, default: "system"
field :published, :boolean, default: true
field :meta_description, :string
field :meta_robots, :string, default: "index, follow"
field :focus_keyword, :string
field :show_in_nav, :boolean, default: false
field :nav_label, :string
field :nav_position, :integer
field :url_slug, :string
field :og_image_id, :binary_id
timestamps(type: :utc_datetime)
end
@@ -52,9 +62,11 @@ defmodule Berrypod.Pages.Page do
def system_changeset(page, attrs) do
page
|> cast(attrs, [:slug, :title, :blocks, :url_slug])
|> cast(attrs, [:slug, :title, :blocks, :url_slug, :meta_robots, :meta_description])
|> validate_required([:slug, :title, :blocks])
|> validate_inclusion(:slug, @system_slugs)
|> validate_inclusion(:meta_robots, @meta_robots_options)
|> validate_length(:meta_description, max: 300)
|> validate_url_slug()
|> unique_constraint(:slug)
|> unique_constraint(:url_slug)
@@ -69,10 +81,13 @@ defmodule Berrypod.Pages.Page do
:type,
:published,
:meta_description,
:meta_robots,
:focus_keyword,
:show_in_nav,
:nav_label,
:nav_position,
:url_slug
:url_slug,
:og_image_id
])
|> validate_required([:slug, :title])
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
@@ -82,6 +97,8 @@ defmodule Berrypod.Pages.Page do
|> validate_length(:slug, max: 100)
|> validate_length(:title, max: 200)
|> validate_length(:meta_description, max: 300)
|> validate_inclusion(:meta_robots, @meta_robots_options)
|> validate_length(:focus_keyword, max: 100)
|> validate_url_slug()
|> put_defaults()
|> unique_constraint(:slug)
@@ -131,6 +148,7 @@ defmodule Berrypod.Pages.Page do
def system_slugs, do: @system_slugs
def reserved_paths, do: @reserved_paths
def meta_robots_options, do: @meta_robots_options
def system_slug?(slug), do: slug in @system_slugs
def reserved_path?(slug), do: slug in @reserved_paths
end

View File

@@ -258,6 +258,21 @@ defmodule Berrypod.Products do
|> Repo.preload(listing_preloads())
end
@doc """
Lists visible products with all images preloaded (for sitemap generation).
Returns a minimal struct with slug and images (up to 5 per product).
"""
def list_products_for_sitemap do
pi_query = from(pi in ProductImage, where: pi.position <= 4, order_by: pi.position)
Product
|> where([p], p.visible == true and p.status == "active")
|> select([p], struct(p, [:slug, :title]))
|> Repo.all()
|> Repo.preload(images: {pi_query, image: image_preload_query()})
end
@doc """
Like `list_visible_products/1` but returns a `%Pagination{}` struct.

View File

@@ -0,0 +1,459 @@
defmodule Berrypod.SEO.Analyser do
@moduledoc """
SEO analysis functions for pages.
Provides recommendations based on a focus keyword and page content.
Pure functions, no side effects.
"""
@type check :: %{
id: atom(),
label: String.t(),
status: :pass | :fail | :warning,
message: String.t()
}
@doc """
Analyses a page and returns a list of SEO checks.
Expects a map with:
- `:focus_keyword` - the keyword to optimise for
- `:title` - page title
- `:meta_description` - meta description
- `:slug` - URL slug
- `:blocks` - list of page blocks
"""
@spec analyse(map()) :: [check()]
def analyse(page) do
keyword = page[:focus_keyword]
if present?(keyword) do
[
check_keyword_in_title(keyword, page[:title]),
check_keyword_in_description(keyword, page[:meta_description]),
check_keyword_in_url(keyword, page[:slug]),
check_keyword_in_content(keyword, page[:blocks]),
check_title_length(page[:title]),
check_description_length(page[:meta_description]),
check_images_have_alt(page[:blocks]),
check_internal_links(page[:blocks])
]
else
[
%{
id: :no_keyword,
label: "Focus keyword",
status: :warning,
message: "Set a focus keyword to get recommendations"
}
]
end
end
@doc """
Calculates an SEO score from a list of checks.
Returns a percentage (0-100) based on passing checks.
"""
@spec score([check()]) :: integer()
def score(checks) do
total = length(checks)
passes = Enum.count(checks, &(&1.status == :pass))
if total > 0, do: round(passes / total * 100), else: 0
end
@doc """
Returns the score level for display purposes.
"""
@spec score_level(integer()) :: :good | :ok | :poor
def score_level(score) when score >= 80, do: :good
def score_level(score) when score >= 50, do: :ok
def score_level(_), do: :poor
# ── Individual checks ──────────────────────────────────────────────
defp check_keyword_in_title(keyword, title) do
if contains_keyword?(title, keyword) do
%{
id: :keyword_in_title,
label: "Keyword in title",
status: :pass,
message: "The focus keyword appears in the title"
}
else
%{
id: :keyword_in_title,
label: "Keyword in title",
status: :fail,
message: "Add the focus keyword to your title"
}
end
end
defp check_keyword_in_description(keyword, description) do
if contains_keyword?(description, keyword) do
%{
id: :keyword_in_description,
label: "Keyword in description",
status: :pass,
message: "The focus keyword appears in the meta description"
}
else
%{
id: :keyword_in_description,
label: "Keyword in description",
status: :fail,
message: "Add the focus keyword to your meta description"
}
end
end
defp check_keyword_in_url(keyword, slug) do
keyword_slug = slug_from_keyword(keyword)
if slug && String.contains?(String.downcase(slug), keyword_slug) do
%{
id: :keyword_in_url,
label: "Keyword in URL",
status: :pass,
message: "The focus keyword appears in the URL"
}
else
%{
id: :keyword_in_url,
label: "Keyword in URL",
status: :warning,
message: "Consider adding the focus keyword to the URL"
}
end
end
defp check_keyword_in_content(keyword, blocks) do
content = extract_text_content(blocks)
if contains_keyword?(content, keyword) do
%{
id: :keyword_in_content,
label: "Keyword in content",
status: :pass,
message: "The focus keyword appears in the page content"
}
else
%{
id: :keyword_in_content,
label: "Keyword in content",
status: :fail,
message: "Add the focus keyword to your page content"
}
end
end
defp check_title_length(title) do
length = String.length(title || "")
cond do
length == 0 ->
%{
id: :title_length,
label: "Title length",
status: :fail,
message: "Add a page title"
}
length <= 60 ->
%{
id: :title_length,
label: "Title length",
status: :pass,
message: "Title is a good length (#{length} characters)"
}
length <= 70 ->
%{
id: :title_length,
label: "Title length",
status: :warning,
message: "Title is slightly long (#{length} characters, aim for ≤60)"
}
true ->
%{
id: :title_length,
label: "Title length",
status: :fail,
message: "Title is too long (#{length} characters, may be truncated)"
}
end
end
defp check_description_length(description) do
length = String.length(description || "")
cond do
length == 0 ->
%{
id: :description_length,
label: "Description length",
status: :fail,
message: "Add a meta description"
}
length >= 120 and length <= 155 ->
%{
id: :description_length,
label: "Description length",
status: :pass,
message: "Description is a good length (#{length} characters)"
}
length >= 100 and length <= 160 ->
%{
id: :description_length,
label: "Description length",
status: :warning,
message: "Description could be improved (#{length} characters, aim for 120-155)"
}
length < 100 ->
%{
id: :description_length,
label: "Description length",
status: :warning,
message: "Description is short (#{length} characters, aim for 120-155)"
}
true ->
%{
id: :description_length,
label: "Description length",
status: :fail,
message: "Description is too long (#{length} characters, may be truncated)"
}
end
end
defp check_images_have_alt(blocks) do
image_blocks = extract_image_blocks(blocks)
cond do
image_blocks == [] ->
%{
id: :images_have_alt,
label: "Image alt text",
status: :pass,
message: "No images to check"
}
all_have_alt?(image_blocks) ->
%{
id: :images_have_alt,
label: "Image alt text",
status: :pass,
message: "All images have alt text"
}
true ->
missing = count_missing_alt(image_blocks)
%{
id: :images_have_alt,
label: "Image alt text",
status: :warning,
message: "#{missing} image(s) missing alt text"
}
end
end
defp check_internal_links(blocks) do
links = extract_links(blocks)
internal_links = Enum.filter(links, &internal_link?/1)
if internal_links != [] do
%{
id: :internal_links,
label: "Internal links",
status: :pass,
message: "Page has #{length(internal_links)} internal link(s)"
}
else
%{
id: :internal_links,
label: "Internal links",
status: :warning,
message: "Consider adding internal links to other pages"
}
end
end
# ── Helpers ────────────────────────────────────────────────────────
defp present?(nil), do: false
defp present?(""), do: false
defp present?(str) when is_binary(str), do: String.trim(str) != ""
defp present?(_), do: false
defp contains_keyword?(nil, _keyword), do: false
defp contains_keyword?(_text, nil), do: false
defp contains_keyword?(text, keyword) do
text = String.downcase(text)
keyword = String.downcase(String.trim(keyword))
String.contains?(text, keyword)
end
defp slug_from_keyword(keyword) do
keyword
|> String.downcase()
|> String.trim()
|> String.replace(~r/\s+/, "-")
end
defp extract_text_content(nil), do: ""
defp extract_text_content([]), do: ""
defp extract_text_content(blocks) when is_list(blocks) do
blocks
|> Enum.map(&extract_block_text/1)
|> Enum.join(" ")
end
defp extract_block_text(%{"type" => "hero", "settings" => settings}) do
[
settings["title"],
settings["heading"],
settings["headline"],
settings["description"],
settings["subheading"],
settings["subheadline"]
]
|> Enum.filter(&present?/1)
|> Enum.join(" ")
end
defp extract_block_text(%{"type" => "rich_text", "settings" => settings}) do
settings["content"] || ""
end
defp extract_block_text(%{"type" => "content_body", "settings" => settings}) do
settings["content"] || ""
end
defp extract_block_text(%{"type" => "heading", "settings" => settings}) do
settings["text"] || ""
end
defp extract_block_text(%{"type" => "text_columns", "settings" => settings}) do
col1 = settings["column1_content"] || ""
col2 = settings["column2_content"] || ""
"#{col1} #{col2}"
end
defp extract_block_text(%{"type" => "faq", "settings" => settings}) do
items = settings["items"] || []
items
|> Enum.map(fn item -> "#{item["question"]} #{item["answer"]}" end)
|> Enum.join(" ")
end
defp extract_block_text(_block), do: ""
# Only extract blocks that have both an image field AND an alt text field
# Currently no block types have alt text fields, so this returns empty
# until alt text is added to the block schemas
defp extract_image_blocks(nil), do: []
defp extract_image_blocks([]), do: []
defp extract_image_blocks(blocks) when is_list(blocks) do
# Filter to blocks that have image_id set and support alt text
Enum.filter(blocks, fn block ->
settings = block["settings"] || %{}
has_image = present?(settings["image_id"])
# Only include if it has both image AND alt text field defined
has_image and has_alt_field?(block["type"])
end)
end
# Block types that have an alt text field in their schema
defp has_alt_field?(_type), do: false
defp all_have_alt?(blocks) do
Enum.all?(blocks, fn block ->
settings = block["settings"] || %{}
present?(settings["alt_text"] || settings["image_alt"])
end)
end
defp count_missing_alt(blocks) do
Enum.count(blocks, fn block ->
settings = block["settings"] || %{}
not present?(settings["alt_text"] || settings["image_alt"])
end)
end
defp extract_links(nil), do: []
defp extract_links([]), do: []
defp extract_links(blocks) when is_list(blocks) do
blocks
|> Enum.flat_map(&extract_block_links/1)
|> Enum.uniq()
end
defp extract_block_links(%{"type" => "hero", "settings" => settings}) do
[
settings["cta_href"],
settings["cta_url"],
settings["secondary_cta_href"],
settings["button1_url"],
settings["button2_url"]
]
|> Enum.filter(&present?/1)
end
defp extract_block_links(%{"type" => "cta", "settings" => settings}) do
[settings["cta_url"], settings["cta_href"]]
|> Enum.filter(&present?/1)
end
defp extract_block_links(%{"type" => "rich_text", "settings" => settings}) do
content = settings["content"] || ""
extract_href_links(content) ++ extract_markdown_links(content)
end
defp extract_block_links(%{"type" => "content_body", "settings" => settings}) do
content = settings["content"] || ""
extract_href_links(content) ++ extract_markdown_links(content)
end
defp extract_block_links(%{"type" => "button", "settings" => settings}) do
if present?(settings["url"]), do: [settings["url"]], else: []
end
defp extract_block_links(%{"type" => "banner", "settings" => settings}) do
if present?(settings["button_url"]), do: [settings["button_url"]], else: []
end
defp extract_block_links(_block), do: []
defp extract_href_links(content) do
~r/href=["']([^"']+)["']/
|> Regex.scan(content)
|> Enum.map(fn [_, url] -> url end)
end
defp extract_markdown_links(content) do
~r/\[([^\]]+)\]\(([^)]+)\)/
|> Regex.scan(content)
|> Enum.map(fn [_, _text, url] -> url end)
end
defp internal_link?(url) when is_binary(url) do
String.starts_with?(url, "/") or
String.starts_with?(url, "#") or
not String.contains?(url, "://")
end
defp internal_link?(_), do: false
end

View File

@@ -443,6 +443,36 @@ defmodule Berrypod.Settings do
:ok
end
# ── Business info ─────────────────────────────────────────────
@doc """
Gets business info as a map.
Used for Organization JSON-LD schema and contact details.
Returns a map with keys like:
- "business_type" ("Organization" or "LocalBusiness")
- "business_phone"
- "business_email"
- "business_address" (map with street, city, region, postal_code, country)
"""
def get_business_info do
get_setting("business_info") || %{}
end
@doc """
Updates business info fields.
Merges the provided attrs into existing business info.
"""
def put_business_info(attrs) when is_map(attrs) do
current = get_business_info()
# Stringify keys for consistency
stringified = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
updated = Map.merge(current, stringified)
put_setting("business_info", updated, "json")
end
# Private helpers
defp fetch_setting(key) do

View File

@@ -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" />

View 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

View 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

View File

@@ -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

View File

@@ -0,0 +1,67 @@
defmodule BerrypodWeb.GSCAuthController do
@moduledoc """
Handles Google Search Console OAuth flow.
"""
use BerrypodWeb, :controller
alias Berrypod.GSC.{Cache, OAuth}
@doc """
Initiates the OAuth flow by redirecting to Google's consent screen.
"""
def connect(conn, _params) do
case OAuth.authorize_url() do
{:ok, url} ->
redirect(conn, external: url)
{:error, :missing_client_id} ->
conn
|> put_flash(
:error,
"Google Search Console is not configured. Set GSC_CLIENT_ID and GSC_CLIENT_SECRET environment variables."
)
|> redirect(to: ~p"/admin/gsc")
end
end
@doc """
Handles the OAuth callback from Google.
Exchanges the authorization code for tokens and redirects to the dashboard.
"""
def callback(conn, %{"code" => code}) do
case OAuth.exchange_code(code) do
{:ok, _access_token} ->
# Invalidate any stale cache when reconnecting
Cache.invalidate()
conn
|> put_flash(:info, "Connected to Google Search Console")
|> redirect(to: ~p"/admin/gsc")
{:error, reason} ->
conn
|> put_flash(:error, "Failed to connect: #{inspect(reason)}")
|> redirect(to: ~p"/admin/gsc")
end
end
def callback(conn, %{"error" => error}) do
conn
|> put_flash(:error, "Authorization denied: #{error}")
|> redirect(to: ~p"/admin/gsc")
end
@doc """
Disconnects from Google Search Console by clearing stored tokens.
"""
def disconnect(conn, _params) do
OAuth.disconnect()
Cache.invalidate()
conn
|> put_flash(:info, "Disconnected from Google Search Console")
|> redirect(to: ~p"/admin/gsc")
end
end

View File

@@ -26,48 +26,66 @@ defmodule BerrypodWeb.SeoController do
def sitemap(conn, _params) do
base = BerrypodWeb.Endpoint.url()
products = Products.list_visible_products()
products = Products.list_products_for_sitemap()
categories = Products.list_categories()
static_pages = [
{R.home(), "daily", "1.0"},
{R.collection("all"), "daily", "0.9"},
{R.about(), "monthly", "0.5"},
{R.contact(), "monthly", "0.5"},
{R.delivery(), "monthly", "0.5"},
{R.privacy(), "monthly", "0.3"},
{R.terms(), "monthly", "0.3"}
{R.home(), "daily", "1.0", []},
{R.collection("all"), "daily", "0.9", []},
{R.about(), "monthly", "0.5", []},
{R.contact(), "monthly", "0.5", []},
{R.delivery(), "monthly", "0.5", []},
{R.privacy(), "monthly", "0.3", []},
{R.terms(), "monthly", "0.3", []}
]
category_pages =
Enum.map(categories, fn cat ->
{R.collection(cat.slug), "daily", "0.8"}
{R.collection(cat.slug), "daily", "0.8", []}
end)
product_pages =
Enum.map(products, fn product ->
{R.product(product.slug), "weekly", "0.9"}
images = product_image_entries(product, base)
{R.product(product.slug), "weekly", "0.9", images}
end)
custom_pages =
Pages.list_custom_pages()
|> Enum.filter(& &1.published)
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6"} end)
|> Enum.map(fn page -> {"/#{page.slug}", "weekly", "0.6", []} end)
all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages
entries =
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority} ->
" <url>\n" <>
" <loc>#{base}#{path}</loc>\n" <>
" <changefreq>#{changefreq}</changefreq>\n" <>
" <priority>#{priority}</priority>\n" <>
" </url>"
Enum.map_join(all_pages, "\n", fn {path, changefreq, priority, images} ->
image_tags =
Enum.map_join(images, "\n", fn img ->
"""
<image:image>
<image:loc>#{xml_escape(img.url)}</image:loc>
<image:title>#{xml_escape(img.title)}</image:title>
</image:image>
"""
|> String.trim_trailing()
end)
url_content =
" <loc>#{xml_escape(base <> path)}</loc>\n" <>
" <changefreq>#{changefreq}</changefreq>\n" <>
" <priority>#{priority}</priority>"
if image_tags == "" do
" <url>\n#{url_content}\n </url>"
else
" <url>\n#{url_content}\n#{image_tags}\n </url>"
end
end)
xml = """
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
#{entries}
</urlset>
"""
@@ -76,4 +94,27 @@ defmodule BerrypodWeb.SeoController do
|> put_resp_content_type("application/xml")
|> send_resp(200, xml)
end
defp product_image_entries(product, base_url) do
product.images
|> Enum.take(5)
|> Enum.map(fn product_image ->
image = product_image.image
alt_text = product_image.alt_text || product.title
url = "#{base_url}/image_cache/#{image.id}.webp"
%{url: url, title: alt_text}
end)
end
defp xml_escape(nil), do: ""
defp xml_escape(text) when is_binary(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
end

View File

@@ -0,0 +1,107 @@
defmodule BerrypodWeb.Helpers.SeoHelpers do
@moduledoc """
SEO-related helpers for generating structured data.
"""
import Phoenix.Component, only: [assign: 3]
alias Berrypod.Media
@doc """
Generates FAQPage JSON-LD schema from page blocks.
Extracts FAQ items from any FAQ blocks on the page and builds a valid
FAQPage schema. Returns nil if no FAQ blocks with valid content are found.
## Examples
iex> faq_json_ld([%{"type" => "faq", "settings" => %{"items" => [...]}}])
~s({"@context":"https://schema.org","@type":"FAQPage",...})
"""
def faq_json_ld(blocks) when is_list(blocks) do
questions =
blocks
|> Enum.filter(&(&1["type"] == "faq"))
|> Enum.flat_map(fn block ->
(block["settings"] || %{})["items"] || []
end)
|> Enum.filter(fn item ->
question = String.trim(item["question"] || "")
answer = String.trim(item["answer"] || "")
question != "" and answer != ""
end)
|> Enum.map(fn item ->
%{
"@type" => "Question",
"name" => item["question"],
"acceptedAnswer" => %{
"@type" => "Answer",
"text" => item["answer"]
}
}
end)
if questions != [] do
%{
"@context" => "https://schema.org",
"@type" => "FAQPage",
"mainEntity" => questions
}
|> Jason.encode!(escape: :html_safe)
else
nil
end
end
def faq_json_ld(_), do: nil
@doc """
Assigns an og_image URL to the socket based on page-specific or default image.
Priority: page-specific image > site-wide default > none
"""
def assign_og_image(socket, page, base) do
og_image_id = get_og_image_id(page)
og_image =
cond do
og_image_id ->
Media.get_image(og_image_id)
true ->
Media.get_default_og_image()
end
if og_image do
url = og_image_url(og_image, base)
assign(socket, :og_image, url)
else
socket
end
end
defp get_og_image_id(nil), do: nil
defp get_og_image_id(%{og_image_id: id}), do: id
defp get_og_image_id(page) when is_map(page), do: page[:og_image_id]
defp get_og_image_id(_), do: nil
@doc """
Generates a full URL for an OG image, preferring 1200px width for social sharing.
Falls back to the largest available width if the image is smaller than 1200px.
"""
def og_image_url(image, base) do
path =
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
widths = Berrypod.Images.Optimizer.applicable_widths(image.source_width)
# Prefer 1200px or larger, otherwise use the largest available
width = Enum.find(widths, List.last(widths), &(&1 >= 1200))
"/image_cache/#{image.id}-#{width}.webp"
end
base <> path
end
end

View 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

View File

@@ -7,6 +7,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.Components.SeoChecklist
import BerrypodWeb.Components.SeoPreview
@impl true
def mount(%{"slug" => slug}, _session, socket) do
@@ -503,19 +505,77 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end
end
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images()
{:noreply,
socket
|> assign(:settings_og_picker_open, true)
|> assign(:settings_og_picker_images, images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, :settings_og_picker_open, false)}
end
def handle_event("pick_og_image", %{"image-id" => image_id}, socket) do
image = Media.get_image(image_id)
# Update the form with the new og_image_id
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", image_id)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, image)
|> assign(:settings_og_picker_open, false)}
end
def handle_event("clear_og_image", _params, socket) do
current_params = socket.assigns.settings_form.params || %{}
params = Map.put(current_params, "og_image_id", nil)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:settings_form, form)
|> assign(:settings_og_image, nil)}
end
defp assign_settings_form(socket, slug) do
if Page.system_slug?(slug) do
socket
|> assign(:show_settings, false)
|> assign(:page_struct, nil)
|> assign(:settings_form, nil)
|> assign(:settings_og_image, nil)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
else
page_struct = Pages.get_page_struct(slug)
og_image =
if page_struct.og_image_id, do: Media.get_image(page_struct.og_image_id), else: nil
socket
|> assign(:show_settings, false)
|> assign(:page_struct, page_struct)
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
|> assign(:settings_og_image, og_image)
|> assign(:settings_og_picker_open, false)
|> assign(:settings_og_picker_images, [])
end
end
@@ -631,6 +691,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
label="Meta description"
phx-no-feedback
/>
<.input
field={@settings_form[:meta_robots]}
type="select"
label="Search engine indexing"
options={meta_robots_options()}
/>
<.input
field={@settings_form[:focus_keyword]}
label="Focus keyword"
placeholder="e.g. handmade prints"
/>
<%!-- Social sharing image --%>
<div class="page-settings-og-image">
<label class="admin-label">Social sharing image</label>
<p class="admin-hint">Shown when this page is shared on social media</p>
<input
type="hidden"
name="page[og_image_id]"
value={@settings_og_image && @settings_og_image.id}
/>
<%= if @settings_og_image do %>
<div class="page-settings-og-preview">
<img
src={media_image_url(@settings_og_image, 400)}
alt="Social preview"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Select image
</button>
<% end %>
</div>
<%!-- SEO analysis section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO analysis
</summary>
<div class="page-settings-seo-content">
<.seo_checklist page={seo_analysis_page(@settings_form, @blocks)} />
</div>
</details>
<%!-- SEO preview section --%>
<details class="page-settings-seo-preview">
<summary class="page-settings-seo-summary">
SEO preview
</summary>
<div class="page-settings-seo-content">
<.seo_preview
title={seo_preview_title(@settings_form, @site_name)}
description={seo_preview_description(@settings_form)}
url={seo_preview_url(@settings_form)}
og_image={seo_preview_og_image(@settings_og_image)}
site_name={@site_name}
/>
</div>
</details>
<div class="page-settings-row">
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
<.input
@@ -717,6 +859,52 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
search={@image_picker_search}
upload={@uploads.image_picker_upload}
/>
<%!-- OG image picker modal --%>
<.og_image_picker :if={@settings_og_picker_open} images={@settings_og_picker_images} />
</div>
"""
end
defp og_image_picker(assigns) do
~H"""
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
<div class="admin-modal-header">
<h2>Select social sharing image</h2>
<button
type="button"
phx-click="hide_og_picker"
class="admin-modal-close"
aria-label="Close"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<p class="admin-modal-hint">
Recommended: 1200×630px for best results on social platforms
</p>
<div class="admin-modal-body">
<div class="image-picker-grid">
<button
:for={image <- @images}
type="button"
phx-click="pick_og_image"
phx-value-image-id={image.id}
class="image-picker-item"
>
<img
src={media_image_url(image, 200)}
alt={image.alt || ""}
loading="lazy"
/>
</button>
</div>
<%= if @images == [] do %>
<p class="image-picker-empty">No images in media library yet</p>
<% end %>
</div>
</div>
</div>
"""
end
@@ -864,4 +1052,62 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
defp default_nav_items("header"), do: Site.default_header_nav()
defp default_nav_items("footer"), do: Site.default_footer_nav()
defp meta_robots_options do
[
{"Index this page (default)", "index, follow"},
{"Don't index this page", "noindex, follow"},
{"Index but don't follow links", "index, nofollow"},
{"Don't index or follow links", "noindex, nofollow"}
]
end
# SEO preview helpers — extract values from the live form
defp seo_preview_title(form, site_name) do
title = Phoenix.HTML.Form.input_value(form, :title) || ""
if title == "", do: site_name, else: "#{title} · #{site_name}"
end
defp seo_preview_description(form) do
Phoenix.HTML.Form.input_value(form, :meta_description) || ""
end
defp seo_preview_url(form) do
slug = Phoenix.HTML.Form.input_value(form, :slug) || ""
base = BerrypodWeb.Endpoint.url()
if slug == "", do: base, else: "#{base}/#{slug}"
end
# Build page map for SEO analysis
defp seo_analysis_page(form, blocks) do
%{
focus_keyword: Phoenix.HTML.Form.input_value(form, :focus_keyword),
title: Phoenix.HTML.Form.input_value(form, :title),
meta_description: Phoenix.HTML.Form.input_value(form, :meta_description),
slug: Phoenix.HTML.Form.input_value(form, :slug),
blocks: blocks
}
end
defp seo_preview_og_image(nil), do: nil
defp seo_preview_og_image(image) do
media_image_url(image, 400)
end
# Generate a URL for a media library image at the given width
defp media_image_url(nil, _width), do: nil
defp media_image_url(image, width) do
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
applicable_width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> Enum.find(&(&1 >= width))
"/image_cache/#{image.id}-#{applicable_width || width}.webp"
end
end
end

View File

@@ -1,6 +1,7 @@
defmodule BerrypodWeb.Admin.Settings do
use BerrypodWeb, :live_view
alias Berrypod.Media
alias Berrypod.Products
alias Berrypod.Settings
alias Berrypod.Stripe.Setup, as: StripeSetup
@@ -19,7 +20,17 @@ defmodule BerrypodWeb.Admin.Settings do
|> assign(:signing_secret_status, :idle)
|> assign_stripe_state()
|> assign_products_state()
|> assign_url_prefixes()}
|> assign_url_prefixes()
|> assign_og_image_state()}
end
defp assign_og_image_state(socket) do
og_image = Media.get_default_og_image()
socket
|> assign(:og_image, og_image)
|> assign(:og_picker_open, false)
|> assign(:og_picker_images, [])
end
defp assign_url_prefixes(socket) do
@@ -170,6 +181,44 @@ defmodule BerrypodWeb.Admin.Settings do
end
end
# -- Events: OG image --
def handle_event("show_og_picker", _params, socket) do
images = Media.list_images() |> Enum.take(50)
{:noreply, assign(socket, og_picker_open: true, og_picker_images: images)}
end
def handle_event("hide_og_picker", _params, socket) do
{:noreply, assign(socket, og_picker_open: false)}
end
def handle_event("pick_og_image", %{"id" => id}, socket) do
image = Media.get_image(id)
if image do
Media.update_image_type(image, "default_og")
{:noreply,
socket
|> assign(:og_image, image)
|> assign(:og_picker_open, false)
|> put_flash(:info, "Default social image set")}
else
{:noreply, put_flash(socket, :error, "Image not found")}
end
end
def handle_event("clear_og_image", _params, socket) do
if socket.assigns.og_image do
Media.update_image_type(socket.assigns.og_image, "media")
end
{:noreply,
socket
|> assign(:og_image, nil)
|> put_flash(:info, "Default social image removed")}
end
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@@ -502,10 +551,113 @@ defmodule BerrypodWeb.Admin.Settings do
</form>
</div>
</section>
<%!-- Default social image --%>
<section class="admin-section">
<h2 class="admin-section-title">Default social image</h2>
<p class="admin-section-desc">
The image shown when pages are shared on social media.
Individual pages can override this in their settings.
</p>
<div class="admin-section-body">
<%= if @og_image do %>
<div class="page-settings-og-preview">
<img
src={og_image_url(@og_image)}
alt="Current social image"
class="page-settings-og-thumb"
/>
<div class="page-settings-og-actions">
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Change
</button>
<button
type="button"
phx-click="clear_og_image"
class="admin-link-danger"
>
Remove
</button>
</div>
</div>
<% else %>
<button
type="button"
phx-click="show_og_picker"
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon name="hero-photo" class="size-4" /> Choose image
</button>
<% end %>
</div>
</section>
<.og_picker_modal
:if={@og_picker_open}
images={@og_picker_images}
/>
</div>
"""
end
defp og_picker_modal(assigns) do
~H"""
<div class="admin-modal-backdrop" phx-click="hide_og_picker">
<div class="admin-modal admin-modal-lg" phx-click-away="hide_og_picker">
<div class="admin-modal-header">
<h3>Choose social image</h3>
<button type="button" phx-click="hide_og_picker" class="admin-modal-close">
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<div class="admin-modal-body">
<p class="admin-help-text" style="margin-bottom: 1rem;">
Choose an image from your media library.
Recommended size: 1200×630 pixels.
</p>
<%= if @images == [] do %>
<p class="admin-text-secondary">
No images in your media library.
<.link navigate={~p"/admin/media"} class="admin-link">Upload images</.link>
first.
</p>
<% else %>
<div class="og-picker-grid">
<%= for image <- @images do %>
<button
type="button"
phx-click="pick_og_image"
phx-value-id={image.id}
class="og-picker-item"
>
<img src={og_image_url(image)} alt={image.filename} />
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
defp og_image_url(image) do
if image.is_svg do
"/image_cache/#{image.id}.webp"
else
applicable_width =
image.source_width
|> Berrypod.Images.Optimizer.applicable_widths()
|> Enum.find(&(&1 >= 400))
"/image_cache/#{image.id}-#{applicable_width || 400}.webp"
end
end
# -- Function components --
attr :color, :string, required: true

View File

@@ -14,10 +14,21 @@ defmodule BerrypodWeb.Shop.Pages.Cart do
socket
|> assign(:page_title, "Cart")
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -50,6 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
|> assign(:page_title, "Order confirmed")
|> assign(:order, order)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
@@ -58,6 +59,16 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
{:redirect, redirect(socket, to: R.home())}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
alias Berrypod.{Pages, Pagination, Products}
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
@sort_options [
@@ -20,16 +21,29 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
def init(socket, _params, _uri) do
page = Pages.get_page("collection")
base = BerrypodWeb.Endpoint.url()
socket =
socket
|> assign(:page, page)
|> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured")
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
# When accessed via custom URL (e.g. /shop) without a collection slug, show all products
def handle_params(params, uri, socket) when not is_map_key(params, "slug") do
handle_params(Map.put(params, "slug", "all"), uri, socket)

View File

@@ -10,10 +10,12 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
alias BerrypodWeb.R
alias Berrypod.Orders.OrderNotifier
alias Berrypod.Pages
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.OrderLookupController
def init(socket, _params, _uri) do
page = Pages.get_page("contact")
base = BerrypodWeb.Endpoint.url()
socket =
socket
@@ -25,10 +27,22 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
|> assign(:og_url, R.url(R.contact()))
|> assign(:tracking_state, :idle)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
alias Berrypod.LegalPages
alias Berrypod.Pages
alias Berrypod.Theme.PreviewData
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
def init(socket, _params, _uri) do
@@ -27,10 +28,22 @@ defmodule BerrypodWeb.Shop.Pages.Content do
|> assign(seo)
|> assign(:page, page)
|> assign(:content_blocks, content_blocks)
|> assign(:json_ld, SeoHelpers.faq_json_ld(page && page.blocks))
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_event(_event, _params, _socket), do: :cont
# Returns {seo_assigns, content_blocks} for each content page

View File

@@ -6,6 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
alias BerrypodWeb.Helpers.SeoHelpers
def init(socket, _params, _uri) do
# Custom pages load in handle_params based on slug
@@ -55,10 +56,13 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
type: page.type,
published: page.published,
meta_description: page.meta_description,
meta_robots: page.meta_robots,
focus_keyword: page.focus_keyword,
url_slug: page.url_slug,
show_in_nav: page.show_in_nav,
nav_label: page.nav_label,
nav_position: page.nav_position
nav_position: page.nav_position,
og_image_id: page.og_image_id
}
end
@@ -91,6 +95,7 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
defp maybe_assign_meta(socket, page, base) do
socket
|> assign(:og_url, base <> "/#{page.slug}")
|> assign(:json_ld, SeoHelpers.faq_json_ld(page.blocks))
|> then(fn s ->
if page.meta_description do
assign(s, :page_description, page.meta_description)
@@ -98,5 +103,15 @@ defmodule BerrypodWeb.Shop.Pages.CustomPage do
s
end
end)
|> then(fn s ->
meta_robots = page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(s, :meta_robots, meta_robots)
else
s
end
end)
|> SeoHelpers.assign_og_image(page, base)
end
end

View File

@@ -5,7 +5,8 @@ defmodule BerrypodWeb.Shop.Pages.Home do
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Berrypod.Pages
alias Berrypod.{Pages, Settings}
alias BerrypodWeb.Helpers.SeoHelpers
def init(socket, _params, _uri) do
page = Pages.get_page("home")
@@ -14,28 +15,145 @@ defmodule BerrypodWeb.Shop.Pages.Home do
base = BerrypodWeb.Endpoint.url()
site_name = socket.assigns.site_name
org_ld =
Jason.encode!(
%{
"@context" => "https://schema.org",
"@type" => "Organization",
"name" => site_name,
"url" => base <> "/"
},
escape: :html_safe
)
org_ld = build_organization_json_ld(socket.assigns, base, site_name)
json_ld = combine_json_ld([org_ld, SeoHelpers.faq_json_ld(page.blocks)])
socket =
socket
|> assign(:page_title, "Home")
|> assign(:og_url, base <> "/")
|> assign(:json_ld, org_ld)
|> assign(:json_ld, json_ld)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
|> SeoHelpers.assign_og_image(page, base)
|> assign(extra)
{:noreply, socket}
end
# Combine multiple JSON-LD scripts into a single output (newline-separated)
defp combine_json_ld(ld_list) do
ld_list
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
[single] -> single
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
end
end
defp build_organization_json_ld(assigns, base_url, site_name) do
business_info = Settings.get_business_info()
org_type =
if business_info["business_type"] == "LocalBusiness",
do: "LocalBusiness",
else: "Organization"
org = %{
"@context" => "https://schema.org",
"@type" => org_type,
"name" => site_name,
"url" => base_url <> "/"
}
org
|> maybe_add_logo(assigns[:logo_image], base_url)
|> maybe_add_contact_point(business_info)
|> maybe_add_address(business_info)
|> maybe_add_same_as(assigns[:social_links])
|> Jason.encode!(escape: :html_safe)
end
defp maybe_add_logo(org, nil, _base_url), do: org
defp maybe_add_logo(org, logo_image, base_url) do
logo_url = base_url <> "/image_cache/#{logo_image.id}.webp"
Map.put(org, "logo", logo_url)
end
defp maybe_add_contact_point(org, business_info) do
phone = business_info["business_phone"]
email = business_info["business_email"]
cond do
present?(phone) and present?(email) ->
Map.put(org, "contactPoint", [
%{"@type" => "ContactPoint", "telephone" => phone, "contactType" => "customer service"},
%{"@type" => "ContactPoint", "email" => email, "contactType" => "customer service"}
])
present?(phone) ->
Map.put(org, "contactPoint", %{
"@type" => "ContactPoint",
"telephone" => phone,
"contactType" => "customer service"
})
present?(email) ->
Map.put(org, "contactPoint", %{
"@type" => "ContactPoint",
"email" => email,
"contactType" => "customer service"
})
true ->
org
end
end
defp maybe_add_address(org, business_info) do
street = business_info["address_street"]
city = business_info["address_city"]
country = business_info["address_country"]
if present?(street) or present?(city) or present?(country) do
address = %{"@type" => "PostalAddress"}
address =
address
|> maybe_put("streetAddress", street)
|> maybe_put("addressLocality", city)
|> maybe_put("addressRegion", business_info["address_region"])
|> maybe_put("postalCode", business_info["address_postal_code"])
|> maybe_put("addressCountry", country)
Map.put(org, "address", address)
else
org
end
end
defp maybe_add_same_as(org, nil), do: org
defp maybe_add_same_as(org, []), do: org
defp maybe_add_same_as(org, social_links) do
urls =
social_links
|> Enum.map(& &1.url)
|> Enum.filter(&present?/1)
if urls != [], do: Map.put(org, "sameAs", urls), else: org
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp present?(nil), do: false
defp present?(""), do: false
defp present?(_), do: true
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -18,10 +18,21 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
socket
|> assign(:lookup_email, session["email_session"])
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(%{"order_number" => order_number}, _uri, socket) do
email = socket.assigns.lookup_email

View File

@@ -19,6 +19,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
|> assign(:page_title, "Your orders")
|> assign(:lookup_email, email)
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
socket =
if email do
@@ -30,6 +31,16 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

View File

@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages, Reviews}
alias BerrypodWeb.Helpers.SeoHelpers
alias BerrypodWeb.R
alias Berrypod.Images.Optimizer
alias Berrypod.Products
@@ -57,6 +58,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
page = Pages.get_page("pdp")
is_discontinued = product.status == "discontinued"
product_ld =
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
faq_ld = SeoHelpers.faq_json_ld(page.blocks)
combined_ld = combine_json_ld([product_ld, faq_ld])
socket =
socket
|> assign(:page_title, product.title)
@@ -64,10 +71,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:og_type, "product")
|> assign(:og_url, og_url)
|> assign(:og_image, og_image)
|> assign(
:json_ld,
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
)
|> assign(:json_ld, combined_ld)
|> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:quantity, 1)
@@ -78,6 +82,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:review_form, nil)
|> assign(:review_status, nil)
|> assign(:existing_review, nil)
|> maybe_assign_meta_robots(page)
# Check if user has an existing review for this product
socket = load_existing_review(socket)
@@ -488,6 +493,27 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
# Combine multiple JSON-LD scripts into a single output (newline-separated)
defp combine_json_ld(ld_list) do
ld_list
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
[single] -> single
many -> Enum.join(many, "\n</script>\n<script type=\"application/ld+json\">\n")
end
end
defp format_review_for_display(review) do
%{
id: review.id,

View File

@@ -16,10 +16,21 @@ defmodule BerrypodWeb.Shop.Pages.Search do
socket
|> assign(:page_title, "Search")
|> assign(:page, page)
|> maybe_assign_meta_robots(page)
{:noreply, socket}
end
defp maybe_assign_meta_robots(socket, page) do
meta_robots = page && page[:meta_robots]
if meta_robots && meta_robots != "index, follow" do
assign(socket, :meta_robots, meta_robots)
else
socket
end
end
def handle_params(params, _uri, socket) do
query = params["q"] || ""
results = if query != "", do: Search.search(query), else: []

View File

@@ -1285,6 +1285,11 @@ defmodule BerrypodWeb.PageEditorHook do
handle_theme_action("remove_header", %{}, socket)
end
defp handle_site_action("update_business_info", %{"business_info" => info_params}, socket) do
socket = handle_business_info_update(socket, info_params)
{:halt, socket}
end
# Catch-all for unknown site actions
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
@@ -1334,6 +1339,20 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:site_dirty, SiteEditorState.dirty?(state))
end
defp handle_business_info_update(socket, params) do
state = socket.assigns.site_state
# Update each business info field
state =
Enum.reduce(params, state, fn {key, value}, acc ->
SiteEditorState.put_business_info(acc, key, value)
end)
socket
|> assign(:site_state, state)
|> assign(:site_dirty, SiteEditorState.dirty?(state))
end
# Helper to update site state and compute dirty flag
defp update_site_state(socket, update_fn) do
state = socket.assigns.site_state

View File

@@ -801,6 +801,39 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
defp render_block(%{block: %{"type" => "faq"}} = assigns) do
settings = assigns.block["settings"] || %{}
title = settings["title"] || ""
items =
(settings["items"] || [])
|> Enum.filter(fn item ->
q = item["question"] || ""
String.trim(q) != ""
end)
assigns =
assigns
|> assign(:faq_title, title)
|> assign(:items, items)
~H"""
<section class="faq-section page-container">
<h2 :if={@faq_title != ""} class="faq-title">{@faq_title}</h2>
<dl class="faq-list">
<details :for={item <- @items} class="faq-item">
<summary class="faq-question">{item["question"]}</summary>
<div class="faq-answer">
<p :for={para <- String.split(item["answer"] || "", ~r/\n{2,}/, trim: true)}>
{para}
</p>
</div>
</details>
</dl>
</section>
"""
end
defp render_block(%{block: %{"type" => "trust_badges"}} = assigns) do
~H"""
<.trust_badges :if={@theme_settings.pdp_trust_badges} />

View File

@@ -152,6 +152,11 @@ defmodule BerrypodWeb.Router do
post "/providers", ProvidersController, :create
post "/providers/:id", ProvidersController, :update
# GSC OAuth routes
get "/gsc/connect", GSCAuthController, :connect
get "/gsc/callback", GSCAuthController, :callback
delete "/gsc/disconnect", GSCAuthController, :disconnect
live_session :admin,
layout: {BerrypodWeb.Layouts, :admin},
on_mount: [
@@ -182,6 +187,7 @@ defmodule BerrypodWeb.Router do
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
live "/redirects", Admin.Redirects, :index
live "/gsc", Admin.GSC, :index
end
# Theme editor redirects to on-site editing

View File

@@ -26,7 +26,7 @@ defmodule BerrypodWeb.SiteEditorState do
state = SiteEditorState.revert(state)
"""
alias Berrypod.Site
alias Berrypod.{Settings, Site}
alias Berrypod.Site.{NavItem, SocialLink}
defstruct [
@@ -37,6 +37,8 @@ defmodule BerrypodWeb.SiteEditorState do
:footer_about,
:footer_copyright,
:show_newsletter,
# Business info (stored in Settings as JSON)
:business_info,
# List fields (separate tables)
:header_nav,
:footer_nav,
@@ -52,6 +54,7 @@ defmodule BerrypodWeb.SiteEditorState do
footer_about: String.t(),
footer_copyright: String.t(),
show_newsletter: boolean(),
business_info: map(),
header_nav: [NavItem.t()],
footer_nav: [NavItem.t()],
social_links: [SocialLink.t()],
@@ -67,6 +70,7 @@ defmodule BerrypodWeb.SiteEditorState do
header_nav = Site.list_nav_items(:header)
footer_nav = Site.list_nav_items(:footer)
social_links = Site.list_social_links()
business_info = Settings.get_business_info()
state = %__MODULE__{
announcement_text: settings.announcement_text,
@@ -75,6 +79,7 @@ defmodule BerrypodWeb.SiteEditorState do
footer_about: settings.footer_about,
footer_copyright: settings.footer_copyright,
show_newsletter: settings.show_newsletter,
business_info: business_info,
header_nav: header_nav,
footer_nav: footer_nav,
social_links: social_links
@@ -102,6 +107,14 @@ defmodule BerrypodWeb.SiteEditorState do
Map.put(state, field, value)
end
@doc """
Update a business info field. Returns updated state.
"""
@spec put_business_info(t(), String.t(), any()) :: t()
def put_business_info(%__MODULE__{business_info: info} = state, key, value) do
%{state | business_info: Map.put(info, key, value)}
end
@doc """
Add a new social link. Returns updated state.
"""
@@ -255,6 +268,11 @@ defmodule BerrypodWeb.SiteEditorState do
state.show_newsletter
)
# Save business info if changed
if state.business_info != state._original.business_info do
Settings.put_business_info(state.business_info)
end
# Sync nav items and social links
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
@@ -278,6 +296,7 @@ defmodule BerrypodWeb.SiteEditorState do
footer_about: original.footer_about,
footer_copyright: original.footer_copyright,
show_newsletter: original.show_newsletter,
business_info: original.business_info,
header_nav: original.header_nav,
footer_nav: original.footer_nav,
social_links: original.social_links
@@ -301,6 +320,7 @@ defmodule BerrypodWeb.SiteEditorState do
footer_about: state.footer_about,
footer_copyright: state.footer_copyright,
show_newsletter: state.show_newsletter,
business_info: state.business_info,
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
social_links: Enum.map(state.social_links, &social_link_snapshot/1)