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

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