complete reviews system (phases 4-6)
All checks were successful
deploy / deploy (push) Successful in 1m4s

- review display: photos with lightbox, verified badge, pagination
- admin moderation: pending/approved/rejected tabs, bulk actions, nav badge
- SEO: JSON-LD AggregateRating and Review markup on product pages
- automation: review request emails 7 days after delivery (Oban worker)
- rating cache: avg/count fields on products, updated on approval
- fix file size validation in media test (10MB limit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-04-01 22:41:27 +01:00
parent 32eb0c6758
commit 6d2d0c9941
26 changed files with 2155 additions and 157 deletions

View File

@@ -47,10 +47,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
)
end
base = BerrypodWeb.Endpoint.url()
og_url = R.url(R.product(slug))
og_image = og_image_url(all_images)
# Load review aggregates for JSON-LD (SEO)
{avg_rating, review_count} = Reviews.average_rating_for_product(product.id)
seo_reviews = Reviews.list_reviews_for_product(product.id, limit: 5)
page = Pages.get_page("pdp")
is_discontinued = product.status == "discontinued"
@@ -61,7 +64,10 @@ 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, base))
|> assign(
:json_ld,
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
)
|> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:quantity, 1)
@@ -137,26 +143,23 @@ defmodule BerrypodWeb.Shop.Pages.Product do
def handle_event("request_review", %{"email" => email}, socket) do
product = socket.assigns.product
# Always show the same message to prevent email enumeration attacks.
# Attacker shouldn't be able to confirm whether an email has purchased a product.
generic_success =
{:info, "If you've purchased this product, we've sent a verification link to your email."}
case Reviews.request_review_verification(email, product.id, product.title) do
{:ok, :sent} ->
{:noreply,
assign(
socket,
:review_status,
{:info, "Check your email for a link to leave your review."}
)}
{:noreply, assign(socket, :review_status, generic_success)}
{:error, :no_purchase} ->
{:noreply,
assign(
socket,
:review_status,
{:error, "We couldn't find a matching order for this product."}
)}
# Don't reveal that this email hasn't purchased
{:noreply, assign(socket, :review_status, generic_success)}
{:error, :already_reviewed} ->
# This one is safe to reveal - they already have a public review
{:noreply,
assign(socket, :review_status, {:error, "You've already reviewed this product."})}
assign(socket, :review_status, {:info, "You've already reviewed this product."})}
{:error, _reason} ->
{:noreply,
@@ -164,6 +167,21 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end
end
def handle_event("load_more_reviews", _params, socket) do
product = socket.assigns.product
current_reviews = socket.assigns[:reviews] || []
offset = length(current_reviews)
# Load the next batch
more_reviews =
Reviews.list_reviews_for_product(product.id, limit: 10, offset: offset)
|> Enum.map(&format_review_for_display/1)
all_reviews = current_reviews ++ more_reviews
{:noreply, assign(socket, :reviews, all_reviews)}
end
def handle_event(_event, _params, _socket), do: :cont
# ── Review helpers ───────────────────────────────────────────────────
@@ -333,7 +351,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# ── JSON-LD and meta helpers ─────────────────────────────────────────
defp product_json_ld(product, url, image, _base) do
defp product_json_ld(product, url, image, avg_rating, review_count, reviews) do
category_slug =
if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"),
@@ -358,27 +376,32 @@ defmodule BerrypodWeb.Shop.Pages.Product do
]
|> Enum.reject(&is_nil/1)
product_data =
%{
"@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
}
}
|> maybe_add_rating(avg_rating, review_count)
|> maybe_add_reviews(reviews)
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
}
},
product_data,
%{
"@type" => "BreadcrumbList",
"itemListElement" => breadcrumbs
@@ -389,6 +412,53 @@ defmodule BerrypodWeb.Shop.Pages.Product do
Jason.encode!(data, escape: :html_safe)
end
defp maybe_add_rating(product_data, nil, _count), do: product_data
defp maybe_add_rating(product_data, _avg, 0), do: product_data
defp maybe_add_rating(product_data, avg_rating, review_count) do
Map.put(product_data, "aggregateRating", %{
"@type" => "AggregateRating",
"ratingValue" => Decimal.to_string(avg_rating),
"reviewCount" => to_string(review_count),
"bestRating" => "5",
"worstRating" => "1"
})
end
defp maybe_add_reviews(product_data, []), do: product_data
defp maybe_add_reviews(product_data, reviews) do
review_data =
Enum.map(reviews, fn review ->
review_item = %{
"@type" => "Review",
"author" => %{"@type" => "Person", "name" => review.author_name},
"datePublished" => Date.to_iso8601(DateTime.to_date(review.inserted_at)),
"reviewRating" => %{
"@type" => "Rating",
"ratingValue" => to_string(review.rating),
"bestRating" => "5",
"worstRating" => "1"
}
}
review_item =
if review.body && review.body != "" do
Map.put(review_item, "reviewBody", review.body)
else
review_item
end
if review.title && review.title != "" do
Map.put(review_item, "name", review.title)
else
review_item
end
end)
Map.put(product_data, "review", review_data)
end
defp format_price(pence) when is_integer(pence) do
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
end
@@ -417,4 +487,31 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> Kernel.<>("")
end
end
defp format_review_for_display(review) do
%{
id: review.id,
rating: review.rating,
title: review.title,
body: review.body,
author: review.author_name,
date: relative_time(review.inserted_at),
verified: not is_nil(review.order_id),
images: Reviews.get_review_images(review)
}
end
defp relative_time(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)} minutes ago"
diff < 86400 -> "#{div(diff, 3600)} hours ago"
diff < 604_800 -> "#{div(diff, 86400)} days ago"
diff < 2_592_000 -> "#{div(diff, 604_800)} weeks ago"
true -> "#{div(diff, 2_592_000)} months ago"
end
end
end