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

View File

@@ -8,9 +8,12 @@ defmodule BerrypodWeb.Shop.ReviewForm do
use BerrypodWeb, :live_view
alias Berrypod.{Products, Reviews}
alias Berrypod.{Media, Products, Reviews}
alias Berrypod.Reviews.Review
@max_photos 3
@max_file_size 10_000_000
@impl true
def mount(_params, session, socket) do
email_session = session["email_session"]
@@ -24,6 +27,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|> assign(:form, nil)
|> assign(:submitted, false)
|> assign(:error, nil)
|> assign(:existing_images, [])
|> assign(:removed_image_ids, [])
|> allow_upload(:photos,
accept: ~w(.jpg .jpeg .png .webp .heic),
max_entries: @max_photos,
max_file_size: @max_file_size
)
{:ok, socket}
end
@@ -98,11 +108,15 @@ defmodule BerrypodWeb.Shop.ReviewForm do
changeset = Review.update_changeset(review, %{})
# Load existing images for display
existing_images = Reviews.get_review_images(review)
socket
|> assign(:review, review)
|> assign(:product, product)
|> assign(:form, to_form(changeset))
|> assign(:page_title, "Edit your review")
|> assign(:existing_images, existing_images)
else
assign(socket, :error, "You don't have permission to edit this review.")
end
@@ -144,11 +158,30 @@ defmodule BerrypodWeb.Shop.ReviewForm do
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("cancel_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :photos, ref)}
end
def handle_event("remove_image", %{"id" => image_id}, socket) do
removed = [image_id | socket.assigns.removed_image_ids]
existing = Enum.reject(socket.assigns.existing_images, &(&1.id == image_id))
{:noreply,
socket
|> assign(:removed_image_ids, removed)
|> assign(:existing_images, existing)}
end
defp create_review(socket, params) do
# Consume uploads at submit time - they're ready since auto_upload: true
# means files are uploaded as soon as selected
image_ids = consume_photo_uploads(socket)
params =
params
|> Map.put("product_id", socket.assigns.product.id)
|> Map.put("email", socket.assigns.email)
|> Map.put("image_ids", image_ids)
case Reviews.create_review(params) do
{:ok, _review} ->
@@ -163,6 +196,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end
defp update_review(socket, params) do
# Consume uploads at submit time
new_image_ids = consume_photo_uploads(socket)
# Keep existing images that weren't removed, plus new uploads
kept_ids =
socket.assigns.existing_images
|> Enum.map(& &1.id)
|> Enum.reject(&(&1 in socket.assigns.removed_image_ids))
all_image_ids = kept_ids ++ new_image_ids
params = Map.put(params, "image_ids", all_image_ids)
case Reviews.update_review(socket.assigns.review, params) do
{:ok, _review} ->
{:noreply,
@@ -175,6 +221,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end
end
# Consume uploaded photos and save them to the database.
# Returns a list of image IDs for the successfully saved images.
# Uses async variant to avoid blocking - image processing happens in Oban.
defp consume_photo_uploads(socket) do
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
case Media.upload_from_entry_async(path, entry, "review") do
{:ok, image} -> {:ok, image.id}
{:error, _reason} -> {:ok, nil}
end
end)
|> Enum.reject(&is_nil/1)
end
@impl true
def render(assigns) do
~H"""
@@ -185,7 +244,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<% @error -> %>
<.error_message error={@error} product={@product} review={@review} />
<% @form -> %>
<.review_form form={@form} product={@product} review={@review} />
<.review_form
form={@form}
product={@product}
review={@review}
uploads={@uploads}
existing_images={@existing_images}
/>
<% true -> %>
<p>Loading...</p>
<% end %>
@@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<div class="review-error">
<h1>Unable to leave review</h1>
<p>{@error}</p>
<%= if @product do %>
<.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title}
</.link>
<%= if @review do %>
<.link
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
class="review-edit-link"
>
Edit your existing review
</.link>
<% end %>
<% else %>
<.link href={BerrypodWeb.R.home()} class="review-back-link">
Back to shop
</.link>
<% end %>
<.link :if={@product} href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title}
</.link>
<.link
:if={@product && @review}
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
class="review-edit-link"
>
Edit your existing review
</.link>
<.link :if={!@product} href={BerrypodWeb.R.home()} class="review-back-link">
Back to shop
</.link>
</div>
"""
end
@@ -255,6 +316,7 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
<div class="review-form-field">
<label class="review-form-label">Rating</label>
<input type="hidden" name="review[rating]" value={@form[:rating].value} />
<.star_rating_input rating={@form[:rating].value} />
<p
:for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)}
@@ -322,6 +384,8 @@ defmodule BerrypodWeb.Shop.ReviewForm do
</p>
</div>
<.photo_upload_section uploads={@uploads} existing_images={@existing_images} />
<button type="submit" class="review-form-submit">
{if @review, do: "Update review", else: "Submit review"}
</button>
@@ -330,28 +394,140 @@ defmodule BerrypodWeb.Shop.ReviewForm do
"""
end
defp star_rating_input(assigns) do
rating = assigns.rating
rating = if is_binary(rating), do: String.to_integer(rating), else: rating
defp photo_upload_section(assigns) do
# Count total photos (existing + pending uploads)
total_photos = length(assigns.existing_images) + length(assigns.uploads.photos.entries)
can_add_more = total_photos < 3
assigns = assign(assigns, :rating, rating)
assigns = assign(assigns, :can_add_more, can_add_more)
~H"""
<div class="star-rating-input">
<%= for i <- 1..5 do %>
<button
type="button"
phx-click="set_rating"
phx-value-rating={i}
class={"star-rating-btn #{if @rating && @rating >= i, do: "star-filled", else: "star-empty"}"}
aria-label={"Rate #{i} stars"}
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
<div class="review-form-field">
<label class="review-form-label">
Photos <span class="review-form-optional">(optional)</span>
</label>
<div class="review-photo-previews">
<%!-- Existing images (from editing a review) --%>
<div :for={image <- @existing_images} class="review-photo-preview">
<img src={image_thumb_url(image)} alt="Review photo" />
<button
type="button"
class="review-photo-remove"
phx-click="remove_image"
phx-value-id={image.id}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<%!-- Pending upload entries (uploaded to server, consumed on submit) --%>
<div :for={entry <- @uploads.photos.entries} class="review-photo-preview">
<.live_img_preview entry={entry} />
<button
type="button"
class="review-photo-remove"
phx-click="cancel_upload"
phx-value-ref={entry.ref}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div :if={entry.progress > 0 and entry.progress < 100} class="review-photo-progress">
<div class="review-photo-progress-bar" style={"width: #{entry.progress}%"}></div>
</div>
</div>
<.live_file_input upload={@uploads.photos} class="sr-only" />
<label :if={@can_add_more} class="review-photo-add" for={@uploads.photos.ref}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</button>
<% end %>
<span>Add photo</span>
</label>
</div>
<p
:for={entry <- @uploads.photos.entries}
:if={entry.client_type not in ~w(image/jpeg image/png image/webp image/heic)}
class="review-form-error"
>
{entry.client_name}: not a supported image type
</p>
<p :for={err <- upload_errors(@uploads.photos)} class="review-form-error">
{upload_error_to_string(err)}
</p>
<p class="review-form-hint">You can add up to 3 photos (max 10MB each)</p>
</div>
"""
end
defp upload_error_to_string(:too_large), do: "File is too large (max 10MB)"
defp upload_error_to_string(:too_many_files), do: "Too many files (max 3)"
defp upload_error_to_string(:not_accepted), do: "File type not supported"
defp upload_error_to_string(_), do: "Upload error"
defp image_thumb_url(image) do
if image.variants_status == "complete" do
"/image_cache/#{image.id}-thumb.jpg"
else
"/images/#{image.id}"
end
end
defp star_rating_input(assigns) do
assigns = assign(assigns, :rating, parse_rating(assigns.rating))
~H"""
<div class="star-rating-input">
<button
:for={i <- 1..5}
type="button"
phx-click="set_rating"
phx-value-rating={i}
class={["star-rating-btn", if(@rating && @rating >= i, do: "star-filled", else: "star-empty")]}
aria-label={"Rate #{i} stars"}
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</div>
"""
end
defp parse_rating(""), do: nil
defp parse_rating(s) when is_binary(s), do: String.to_integer(s)
defp parse_rating(rating), do: rating
end