add JSON-LD structured data
Product pages: Product schema (name, description, image, price/currency, availability) + BreadcrumbList (Home > Category > Product). Home page: Organization schema (name, url). Uses Jason with html_safe escaping so the JSON is safe to embed in <script> tags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0f1135256d
commit
4e36b654d3
@ -103,7 +103,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
|
||||
| | **SEO** | | | |
|
||||
| ~~58~~ | ~~Page titles with separators across all pages~~ | — | 1h | done |
|
||||
| ~~59~~ | ~~Open Graph + Twitter Card meta tags (products, collections, home)~~ | 58 | 2h | done |
|
||||
| 60 | Structured data / JSON-LD (Product, BreadcrumbList, Organization) | 59 | 2h | planned |
|
||||
| ~~60~~ | ~~Structured data / JSON-LD (Product, BreadcrumbList, Organization)~~ | 59 | 2h | done |
|
||||
| ~~61~~ | ~~Canonical URLs, robots.txt, sitemap.xml~~ | 59 | 1.5h | done |
|
||||
| ~~62~~ | ~~Meta descriptions (per-page, auto-generated fallbacks)~~ | 58 | 1h | done |
|
||||
| | **Profit-aware pricing & sales** ([plan](docs/plans/profit-aware-pricing.md)) | | | |
|
||||
|
||||
@ -40,6 +40,11 @@
|
||||
<% end %>
|
||||
<meta name="twitter:title" content={og_title} />
|
||||
<meta name="twitter:description" content={og_description} />
|
||||
<%= if assigns[:json_ld] do %>
|
||||
<script type="application/ld+json">
|
||||
<%= Phoenix.HTML.raw(assigns[:json_ld]) %>
|
||||
</script>
|
||||
<% end %>
|
||||
<!-- Preload critical fonts for the current typography preset -->
|
||||
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
|
||||
@theme_settings.typography,
|
||||
|
||||
@ -7,10 +7,25 @@ defmodule BerrypodWeb.Shop.Home do
|
||||
def mount(_params, _session, socket) do
|
||||
products = Products.list_visible_products(limit: 8)
|
||||
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
site_name = socket.assigns.theme_settings.site_name
|
||||
|
||||
org_ld =
|
||||
Jason.encode!(
|
||||
%{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "Organization",
|
||||
"name" => site_name,
|
||||
"url" => base <> "/"
|
||||
},
|
||||
escape: :html_safe
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Home")
|
||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/")
|
||||
|> assign(:og_url, base <> "/")
|
||||
|> assign(:json_ld, org_ld)
|
||||
|> assign(:products, products)
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
@ -48,13 +48,18 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
)
|
||||
end
|
||||
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
og_url = base <> "/products/#{slug}"
|
||||
og_image = og_image_url(all_images)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:page_description, meta_description(product.description))
|
||||
|> assign(:og_type, "product")
|
||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/products/#{slug}")
|
||||
|> assign(:og_image, og_image_url(all_images))
|
||||
|> assign(:og_url, og_url)
|
||||
|> assign(:og_image, og_image)
|
||||
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
||||
|> assign(:product, product)
|
||||
|> assign(:all_images, all_images)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
@ -219,6 +224,71 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
"""
|
||||
end
|
||||
|
||||
defp product_json_ld(product, url, image, base) do
|
||||
category_slug =
|
||||
if product.category,
|
||||
do: product.category |> String.downcase() |> String.replace(" ", "-"),
|
||||
else: "all"
|
||||
|
||||
breadcrumbs =
|
||||
[
|
||||
%{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"},
|
||||
product.category &&
|
||||
%{
|
||||
"@type" => "ListItem",
|
||||
"position" => 2,
|
||||
"name" => product.category,
|
||||
"item" => base <> "/collections/#{category_slug}"
|
||||
},
|
||||
%{
|
||||
"@type" => "ListItem",
|
||||
"position" => if(product.category, do: 3, else: 2),
|
||||
"name" => product.title,
|
||||
"item" => url
|
||||
}
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
data = %{
|
||||
"@context" => "https://schema.org",
|
||||
"@graph" => [
|
||||
%{
|
||||
"@type" => "Product",
|
||||
"name" => product.title,
|
||||
"description" => plain_text(product.description),
|
||||
"image" => Enum.reject([image], &is_nil/1),
|
||||
"url" => url,
|
||||
"offers" => %{
|
||||
"@type" => "Offer",
|
||||
"price" => format_price(product.cheapest_price),
|
||||
"priceCurrency" => "GBP",
|
||||
"availability" =>
|
||||
if(product.in_stock,
|
||||
do: "https://schema.org/InStock",
|
||||
else: "https://schema.org/OutOfStock"
|
||||
),
|
||||
"url" => url
|
||||
}
|
||||
},
|
||||
%{
|
||||
"@type" => "BreadcrumbList",
|
||||
"itemListElement" => breadcrumbs
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Jason.encode!(data, escape: :html_safe)
|
||||
end
|
||||
|
||||
defp format_price(pence) when is_integer(pence) do
|
||||
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
|
||||
end
|
||||
|
||||
defp format_price(_), do: "0.00"
|
||||
|
||||
defp plain_text(nil), do: nil
|
||||
defp plain_text(text), do: String.replace(text, ~r/<[^>]+>/, "")
|
||||
|
||||
defp og_image_url([%{url: "/" <> _ = path} | _]), do: BerrypodWeb.Endpoint.url() <> path
|
||||
defp og_image_url(_), do: nil
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user