complete reviews system (phases 4-6)
All checks were successful
deploy / deploy (push) Successful in 1m4s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user