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

@@ -951,18 +951,189 @@ defmodule BerrypodWeb.ShopComponents.Content do
"""
end
@doc """
Renders a generic image lightbox dialog.
Reusable by product galleries, review photos, or any image gallery.
Uses the Lightbox JS hook for keyboard navigation and modal behavior.
## Attributes
* `id` - Required. Unique ID for the dialog element.
* `images` - Required. List of image URLs.
* `caption` - Optional. Caption text shown below the image.
## Examples
<.image_lightbox id="review-123-lightbox" images={@image_urls} caption="Customer photo" />
"""
attr :id, :string, required: true
attr :images, :list, required: true
attr :caption, :string, default: nil
def image_lightbox(assigns) do
~H"""
<dialog
class="lightbox"
id={@id}
aria-label="Image gallery"
data-current-index="0"
data-images={Jason.encode!(@images)}
data-show={Phoenix.LiveView.JS.exec("phx-show-lightbox", to: "##{@id}")}
phx-show-lightbox={Phoenix.LiveView.JS.dispatch("lightbox:open", to: "##{@id}")}
phx-hook="Lightbox"
>
<div class="lightbox-content">
<button
type="button"
class="lightbox-close"
aria-label="Close gallery"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:close", to: "##{@id}")}
>
<svg
width="24"
height="24"
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>
<button
type="button"
class="lightbox-nav lightbox-prev"
aria-label="Previous image"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:prev", to: "##{@id}")}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<figure class="lightbox-figure">
<div class="lightbox-image-container">
<img
class="lightbox-image"
src={List.first(@images)}
alt={@caption || "Image"}
width="1200"
height="1200"
/>
</div>
<figcaption :if={@caption} class="lightbox-caption">{@caption}</figcaption>
</figure>
<button
type="button"
class="lightbox-nav lightbox-next"
aria-label="Next image"
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:next", to: "##{@id}")}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div :if={length(@images) > 1} class="lightbox-counter">1 / {length(@images)}</div>
</div>
</dialog>
"""
end
@doc """
Renders review photo thumbnails that open in a lightbox.
## Attributes
* `images` - Required. List of Image structs with `id` and `variants_status`.
* `review_id` - Required. ID of the review (for unique lightbox ID).
## Examples
<.review_photos images={@review.images} review_id={@review.id} />
"""
attr :images, :list, required: true
attr :review_id, :string, required: true
def review_photos(assigns) do
# Build URLs from image structs
thumb_urls =
Enum.map(assigns.images, fn img ->
if img.variants_status == "complete" do
"/image_cache/#{img.id}-thumb.jpg"
else
"/images/#{img.id}"
end
end)
full_urls =
Enum.map(assigns.images, fn img ->
if img.variants_status == "complete" do
"/image_cache/#{img.id}-800.webp"
else
"/images/#{img.id}"
end
end)
assigns =
assigns
|> assign(:thumb_urls, thumb_urls)
|> assign(:full_urls, full_urls)
~H"""
<div :if={@images != []} class="review-photos">
<button
:for={{thumb, idx} <- Enum.with_index(@thumb_urls)}
type="button"
class="review-photo-thumb"
phx-click={
Phoenix.LiveView.JS.exec("data-show", to: "#review-#{@review_id}-lightbox")
|> Phoenix.LiveView.JS.set_attribute(
{"data-current-index", to_string(idx)},
to: "#review-#{@review_id}-lightbox"
)
}
>
<img src={thumb} alt="Customer photo" loading="lazy" />
</button>
<.image_lightbox
id={"review-#{@review_id}-lightbox"}
images={@full_urls}
caption="Customer photo"
/>
</div>
"""
end
@doc """
Renders a customer reviews section with collapsible header and review cards.
## Attributes
* `reviews` - Required. List of review maps with:
- `id` - Review ID
- `rating` - Star rating (1-5)
- `title` - Review title
- `body` - Review text
- `author` - Reviewer name
- `date` - Relative date string (e.g., "2 weeks ago")
- `verified` - Boolean, if true shows "Verified purchase" badge
- `images` - Optional. List of Image structs for review photos.
* `average_rating` - Optional. Average rating to show in header. Defaults to nil.
* `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
* `open` - Optional. Whether section is expanded by default. Defaults to true.
@@ -1038,7 +1209,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
<% end %>
</div>
<.shop_button_outline :if={length(@reviews) < @display_count} class="reviews-load-more">
<.shop_button_outline
:if={length(@reviews) < @display_count}
class="reviews-load-more"
phx-click="load_more_reviews"
>
Load more reviews
</.shop_button_outline>
<% else %>
@@ -1121,7 +1296,37 @@ defmodule BerrypodWeb.ShopComponents.Content do
~H"""
<div class={"review-status review-status-#{@type}"}>
{@message}
<svg
:if={@type == :info}
class="review-status-icon"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<svg
:if={@type == :error}
class="review-status-icon"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<p>{@message}</p>
</div>
"""
end
@@ -1131,11 +1336,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
## Attributes
* `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`.
* `review` - Required. Map with `id`, `rating`, `title`, `body`, `author`, `date`, `verified`, and optional `images`.
## Examples
<.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} />
<.review_card review={%{id: "abc", rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true, images: []}} />
"""
attr :review, :map, required: true
@@ -1146,19 +1351,33 @@ defmodule BerrypodWeb.ShopComponents.Content do
<.star_rating rating={@review.rating} />
<span class="review-date">{@review.date}</span>
</div>
<h3 class="review-title">{@review.title}</h3>
<p class="review-body">
<h3 :if={@review.title} class="review-title">{@review.title}</h3>
<p :if={@review.body} class="review-body">
{@review.body}
</p>
<.review_photos
:if={@review[:images] && @review.images != []}
images={@review.images}
review_id={@review.id}
/>
<div class="review-footer">
<span class="review-author">
{@review.author}
</span>
<%= if @review.verified do %>
<span class="review-verified">
Verified purchase
</span>
<% end %>
<span :if={@review.verified} class="review-verified">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
Verified purchase
</span>
</div>
</article>
"""