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
<.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}
/>
<.form for={%{}} phx-submit="search" as={:search} class="admin-row">
No reviews to show.
<.review_row
:for={review <- @reviews}
id={"review-#{review.id}"}
review={review}
images={@images_by_review[review.id] || []}
expanded={@expanded_id == review.id}
/>
<.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"""
"""
end
defp review_row(assigns) do
~H"""
{@review.title}
{@review.body}
No written review — rating only.
<.image_lightbox
id={"admin-review-#{@review.id}-lightbox"}
images={Enum.map(@images, &image_url/1)}
caption="Customer photo"
/>
<.link
navigate={~p"/p/#{@review.product.slug}"}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
View product
"""
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"""
<.icon name={@icon} class="size-3.5" />
{@status}
"""
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