diff --git a/PROGRESS.md b/PROGRESS.md
index ebe806f..5f6f872 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -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)) | | | |
diff --git a/lib/berrypod_web/components/layouts/shop_root.html.heex b/lib/berrypod_web/components/layouts/shop_root.html.heex
index 34d2a8f..fad760b 100644
--- a/lib/berrypod_web/components/layouts/shop_root.html.heex
+++ b/lib/berrypod_web/components/layouts/shop_root.html.heex
@@ -40,6 +40,11 @@
<% end %>
+ <%= if assigns[:json_ld] do %>
+
+ <% end %>
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
@theme_settings.typography,
diff --git a/lib/berrypod_web/live/shop/home.ex b/lib/berrypod_web/live/shop/home.ex
index 44ccb8f..81a4e6d 100644
--- a/lib/berrypod_web/live/shop/home.ex
+++ b/lib/berrypod_web/live/shop/home.ex
@@ -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}
diff --git a/lib/berrypod_web/live/shop/product_show.ex b/lib/berrypod_web/live/shop/product_show.ex
index dbe3170..8b9cdca 100644
--- a/lib/berrypod_web/live/shop/product_show.ex
+++ b/lib/berrypod_web/live/shop/product_show.ex
@@ -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