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:
@@ -126,6 +126,20 @@
|
||||
<.icon name="hero-photo" class="size-5" /> Media
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/reviews"}
|
||||
class={admin_nav_active?(@current_path, "/admin/reviews")}
|
||||
>
|
||||
<.icon name="hero-star" class="size-5" /> Reviews
|
||||
<span
|
||||
:if={@pending_review_count > 0}
|
||||
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
|
||||
>
|
||||
{@pending_review_count}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter"}
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
use Phoenix.Component
|
||||
|
||||
import BerrypodWeb.ShopComponents.Base
|
||||
import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1]
|
||||
import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1, star_rating: 1]
|
||||
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
alias BerrypodWeb.R
|
||||
@@ -191,6 +191,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<%= if @theme_settings.show_prices do %>
|
||||
<.product_price product={@product} variant={@variant} />
|
||||
<% end %>
|
||||
<.product_card_rating product={@product} />
|
||||
<%= if @show_delivery_text do %>
|
||||
<p class="product-card-delivery">
|
||||
Made to order
|
||||
@@ -356,6 +357,33 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :product, :map, required: true
|
||||
|
||||
defp product_card_rating(assigns) do
|
||||
rating_count = Map.get(assigns.product, :rating_count, 0)
|
||||
rating_avg = Map.get(assigns.product, :rating_avg)
|
||||
|
||||
# Round to nearest integer for stars display
|
||||
rating_rounded =
|
||||
if rating_avg do
|
||||
rating_avg |> Decimal.round(0) |> Decimal.to_integer()
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:rating_count, rating_count)
|
||||
|> assign(:rating_rounded, rating_rounded)
|
||||
|
||||
~H"""
|
||||
<div :if={@rating_count > 0} class="product-card-rating">
|
||||
<.star_rating rating={@rating_rounded} size={:sm} />
|
||||
<span class="product-card-rating-count">({@rating_count})</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp variant_defaults(:default),
|
||||
do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true}
|
||||
|
||||
@@ -1260,7 +1288,6 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<div class="lightbox-image-container">
|
||||
<img
|
||||
class="lightbox-image"
|
||||
id="lightbox-image"
|
||||
src={List.first(@images)}
|
||||
alt={@product_name}
|
||||
width="1200"
|
||||
@@ -1286,7 +1313,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lightbox-counter" id="lightbox-counter">1 / {length(@images)}</div>
|
||||
<div class="lightbox-counter">1 / {length(@images)}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user