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:
389
lib/berrypod_web/live/admin/reviews.ex
Normal file
389
lib/berrypod_web/live/admin/reviews.ex
Normal file
@@ -0,0 +1,389 @@
|
||||
defmodule BerrypodWeb.Admin.Reviews do
|
||||
@moduledoc """
|
||||
Admin interface for moderating product reviews.
|
||||
"""
|
||||
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
import BerrypodWeb.ShopComponents.Content, only: [image_lightbox: 1]
|
||||
|
||||
alias Berrypod.Reviews
|
||||
|
||||
@valid_statuses ~w(pending approved rejected)
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Reviews")
|
||||
|> assign(:status_counts, Reviews.count_reviews_by_status())
|
||||
|> assign(:expanded_id, nil)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
status = if params["status"] in @valid_statuses, do: params["status"], else: nil
|
||||
search = params["search"]
|
||||
page_num = Berrypod.Pagination.parse_page(params)
|
||||
|
||||
page =
|
||||
Reviews.list_reviews_paginated(
|
||||
status: status,
|
||||
search: search,
|
||||
page: page_num
|
||||
)
|
||||
|
||||
# Batch preload all images for the page in one query
|
||||
images_by_review = Reviews.preload_review_images(page.items)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:status, status)
|
||||
|> assign(:search, search || "")
|
||||
|> assign(:pagination, page)
|
||||
|> assign(:reviews, page.items)
|
||||
|> assign(:images_by_review, images_by_review)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"status" => status}, socket) do
|
||||
status = if status == "", do: nil, else: status
|
||||
params = build_params(status: status, search: socket.assigns.search)
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/reviews?#{params}")}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"search" => %{"query" => query}}, socket) do
|
||||
search = if query == "", do: nil, else: query
|
||||
params = build_params(status: socket.assigns.status, search: search)
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/reviews?#{params}")}
|
||||
end
|
||||
|
||||
def handle_event("expand", %{"id" => id}, socket) do
|
||||
# Toggle: click again to collapse
|
||||
new_id = if socket.assigns.expanded_id == id, do: nil, else: id
|
||||
{:noreply, assign(socket, :expanded_id, new_id)}
|
||||
end
|
||||
|
||||
def handle_event("approve", %{"id" => id}, socket) do
|
||||
review = Reviews.get_review!(id)
|
||||
|
||||
case Reviews.approve_review(review) do
|
||||
{:ok, updated} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> update_review_in_list(updated)
|
||||
|> update_counts()
|
||||
|> put_flash(:info, "Review approved")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to approve review")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reject", %{"id" => id}, socket) do
|
||||
review = Reviews.get_review!(id)
|
||||
|
||||
case Reviews.reject_review(review) do
|
||||
{:ok, updated} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> update_review_in_list(updated)
|
||||
|> update_counts()
|
||||
|> put_flash(:info, "Review rejected")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to reject review")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
review = Reviews.get_review!(id)
|
||||
|
||||
case Reviews.delete_review(review) do
|
||||
{:ok, _} ->
|
||||
reviews = Enum.reject(socket.assigns.reviews, &(&1.id == id))
|
||||
images_by_review = Map.delete(socket.assigns.images_by_review, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:reviews, reviews)
|
||||
|> assign(:images_by_review, images_by_review)
|
||||
|> assign(:expanded_id, nil)
|
||||
|> update_counts()
|
||||
|> put_flash(:info, "Review deleted")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to delete review")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Reviews
|
||||
</.header>
|
||||
|
||||
<div class="admin-filter-row">
|
||||
<.status_tab status={nil} label="All" count={total_count(@status_counts)} active={@status} />
|
||||
<.status_tab
|
||||
status="pending"
|
||||
label="Pending"
|
||||
count={@status_counts["pending"]}
|
||||
active={@status}
|
||||
/>
|
||||
<.status_tab
|
||||
status="approved"
|
||||
label="Approved"
|
||||
count={@status_counts["approved"]}
|
||||
active={@status}
|
||||
/>
|
||||
<.status_tab
|
||||
status="rejected"
|
||||
label="Rejected"
|
||||
count={@status_counts["rejected"]}
|
||||
active={@status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-filter-row admin-filter-row-end">
|
||||
<.form for={%{}} phx-submit="search" as={:search} class="admin-row">
|
||||
<input
|
||||
type="text"
|
||||
name="search[query]"
|
||||
value={@search}
|
||||
placeholder="Search reviews"
|
||||
class="admin-input admin-input-sm"
|
||||
/>
|
||||
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
|
||||
<.icon name="hero-magnifying-glass-mini" class="size-4" />
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<div id="reviews-list" class="admin-review-list">
|
||||
<div :if={@reviews == []} class="admin-stream-empty">
|
||||
No reviews to show.
|
||||
</div>
|
||||
<.review_row
|
||||
:for={review <- @reviews}
|
||||
id={"review-#{review.id}"}
|
||||
review={review}
|
||||
images={@images_by_review[review.id] || []}
|
||||
expanded={@expanded_id == review.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.admin_pagination
|
||||
page={@pagination}
|
||||
patch={~p"/admin/reviews"}
|
||||
params={build_params(status: @status, search: @search)}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Components ──
|
||||
|
||||
defp status_tab(assigns) do
|
||||
active = assigns.status == assigns.active
|
||||
count = assigns.count || 0
|
||||
show_badge = assigns.status == "pending" and count > 0
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:is_active, active)
|
||||
|> assign(:show_badge, show_badge)
|
||||
|> assign(:count, count)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click="filter"
|
||||
phx-value-status={@status || ""}
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@is_active && "admin-btn-primary",
|
||||
!@is_active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span
|
||||
:if={@show_badge}
|
||||
class="admin-badge admin-badge-sm admin-badge-warning admin-badge-count"
|
||||
>
|
||||
{@count}
|
||||
</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp review_row(assigns) do
|
||||
~H"""
|
||||
<article id={@id} class={["admin-review-row", @expanded && "admin-review-row-expanded"]}>
|
||||
<button type="button" class="admin-review-header" phx-click="expand" phx-value-id={@review.id}>
|
||||
<div class="admin-review-meta">
|
||||
<.status_badge status={@review.status} />
|
||||
<span class="admin-review-product">{@review.product.title}</span>
|
||||
<span class="admin-review-rating">
|
||||
<.icon name="hero-star-solid" class="size-4 admin-star" />
|
||||
{@review.rating}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-review-info">
|
||||
<span class="admin-review-author">{@review.author_name}</span>
|
||||
<span class="admin-review-email">{@review.email}</span>
|
||||
<time class="admin-review-date" datetime={DateTime.to_iso8601(@review.inserted_at)}>
|
||||
{relative_time(@review.inserted_at)}
|
||||
</time>
|
||||
</div>
|
||||
<.icon
|
||||
name={if @expanded, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
|
||||
class="size-5 admin-review-chevron"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div :if={@expanded} class="admin-review-detail">
|
||||
<div class="admin-review-content">
|
||||
<h4 :if={@review.title} class="admin-review-title">{@review.title}</h4>
|
||||
<p :if={@review.body} class="admin-review-body">{@review.body}</p>
|
||||
<p :if={!@review.title && !@review.body} class="admin-review-empty">
|
||||
No written review — rating only.
|
||||
</p>
|
||||
|
||||
<div :if={@images != []} class="admin-review-photos">
|
||||
<button
|
||||
:for={{image, idx} <- Enum.with_index(@images)}
|
||||
type="button"
|
||||
class="admin-review-photo"
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.exec("data-show", to: "#admin-review-#{@review.id}-lightbox")
|
||||
|> Phoenix.LiveView.JS.set_attribute(
|
||||
{"data-current-index", to_string(idx)},
|
||||
to: "#admin-review-#{@review.id}-lightbox"
|
||||
)
|
||||
}
|
||||
>
|
||||
<img src={thumb_url(image)} alt="Customer photo" loading="lazy" />
|
||||
</button>
|
||||
|
||||
<.image_lightbox
|
||||
id={"admin-review-#{@review.id}-lightbox"}
|
||||
images={Enum.map(@images, &image_url/1)}
|
||||
caption="Customer photo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-review-actions">
|
||||
<.link
|
||||
navigate={~p"/p/#{@review.product.slug}"}
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
View product
|
||||
</.link>
|
||||
<button
|
||||
:if={@review.status != "approved"}
|
||||
phx-click="approve"
|
||||
phx-value-id={@review.id}
|
||||
class="admin-btn admin-btn-sm admin-btn-primary"
|
||||
>
|
||||
<.icon name="hero-check-mini" class="size-4" /> Approve
|
||||
</button>
|
||||
<button
|
||||
:if={@review.status != "rejected"}
|
||||
phx-click="reject"
|
||||
phx-value-id={@review.id}
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
<.icon name="hero-x-mark-mini" class="size-4" /> Reject
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={@review.id}
|
||||
data-confirm="Delete this review permanently?"
|
||||
class="admin-btn admin-btn-sm admin-btn-danger"
|
||||
>
|
||||
<.icon name="hero-trash-mini" class="size-4" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{class, icon} =
|
||||
case assigns.status do
|
||||
"pending" -> {"admin-status-pending", "hero-clock-mini"}
|
||||
"approved" -> {"admin-status-approved", "hero-check-circle-mini"}
|
||||
"rejected" -> {"admin-status-rejected", "hero-x-circle-mini"}
|
||||
_ -> {"", "hero-question-mark-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, class: class, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={["admin-status-badge", @class]}>
|
||||
<.icon name={@icon} class="size-3.5" />
|
||||
{@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
defp total_count(counts) do
|
||||
(counts["pending"] || 0) + (counts["approved"] || 0) + (counts["rejected"] || 0)
|
||||
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)}m ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)}h ago"
|
||||
diff < 604_800 -> "#{div(diff, 86400)}d ago"
|
||||
true -> Calendar.strftime(datetime, "%d %b %Y")
|
||||
end
|
||||
end
|
||||
|
||||
defp build_params(opts) do
|
||||
%{}
|
||||
|> then(fn p -> if opts[:status], do: Map.put(p, "status", opts[:status]), else: p end)
|
||||
|> then(fn p -> if opts[:search], do: Map.put(p, "search", opts[:search]), else: p end)
|
||||
end
|
||||
|
||||
defp update_counts(socket) do
|
||||
assign(socket, :status_counts, Reviews.count_reviews_by_status())
|
||||
end
|
||||
|
||||
defp update_review_in_list(socket, updated) do
|
||||
reviews =
|
||||
Enum.map(socket.assigns.reviews, fn review ->
|
||||
if review.id == updated.id, do: %{updated | product: review.product}, else: review
|
||||
end)
|
||||
|
||||
assign(socket, :reviews, reviews)
|
||||
end
|
||||
|
||||
defp thumb_url(image) do
|
||||
if image.variants_status == "complete" do
|
||||
"/image_cache/#{image.id}-thumb.jpg"
|
||||
else
|
||||
"/images/#{image.id}"
|
||||
end
|
||||
end
|
||||
|
||||
defp image_url(image) do
|
||||
if image.variants_status == "complete" do
|
||||
"/image_cache/#{image.id}-800.webp"
|
||||
else
|
||||
"/images/#{image.id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user