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** | | | |
|
| | **SEO** | | | |
|
||||||
| ~~58~~ | ~~Page titles with separators across all pages~~ | — | 1h | done |
|
| ~~58~~ | ~~Page titles with separators across all pages~~ | — | 1h | done |
|
||||||
| ~~59~~ | ~~Open Graph + Twitter Card meta tags (products, collections, home)~~ | 58 | 2h | 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 |
|
| ~~61~~ | ~~Canonical URLs, robots.txt, sitemap.xml~~ | 59 | 1.5h | done |
|
||||||
| ~~62~~ | ~~Meta descriptions (per-page, auto-generated fallbacks)~~ | 58 | 1h | done |
|
| ~~62~~ | ~~Meta descriptions (per-page, auto-generated fallbacks)~~ | 58 | 1h | done |
|
||||||
| | **Profit-aware pricing & sales** ([plan](docs/plans/profit-aware-pricing.md)) | | | |
|
| | **Profit-aware pricing & sales** ([plan](docs/plans/profit-aware-pricing.md)) | | | |
|
||||||
|
|||||||
@ -40,6 +40,11 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<meta name="twitter:title" content={og_title} />
|
<meta name="twitter:title" content={og_title} />
|
||||||
<meta name="twitter:description" content={og_description} />
|
<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 -->
|
<!-- Preload critical fonts for the current typography preset -->
|
||||||
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
|
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
|
||||||
@theme_settings.typography,
|
@theme_settings.typography,
|
||||||
|
|||||||
@ -7,10 +7,25 @@ defmodule BerrypodWeb.Shop.Home do
|
|||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
products = Products.list_visible_products(limit: 8)
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Home")
|
|> assign(:page_title, "Home")
|
||||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/")
|
|> assign(:og_url, base <> "/")
|
||||||
|
|> assign(:json_ld, org_ld)
|
||||||
|> assign(:products, products)
|
|> assign(:products, products)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|||||||
@ -48,13 +48,18 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
base = BerrypodWeb.Endpoint.url()
|
||||||
|
og_url = base <> "/products/#{slug}"
|
||||||
|
og_image = og_image_url(all_images)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, product.title)
|
|> assign(:page_title, product.title)
|
||||||
|> assign(:page_description, meta_description(product.description))
|
|> assign(:page_description, meta_description(product.description))
|
||||||
|> assign(:og_type, "product")
|
|> assign(:og_type, "product")
|
||||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/products/#{slug}")
|
|> assign(:og_url, og_url)
|
||||||
|> assign(:og_image, og_image_url(all_images))
|
|> assign(:og_image, og_image)
|
||||||
|
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
||||||
|> assign(:product, product)
|
|> assign(:product, product)
|
||||||
|> assign(:all_images, all_images)
|
|> assign(:all_images, all_images)
|
||||||
|> assign(:gallery_images, gallery_images)
|
|> assign(:gallery_images, gallery_images)
|
||||||
@ -219,6 +224,71 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
|||||||
"""
|
"""
|
||||||
end
|
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([%{url: "/" <> _ = path} | _]), do: BerrypodWeb.Endpoint.url() <> path
|
||||||
defp og_image_url(_), do: nil
|
defp og_image_url(_), do: nil
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user