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