Files
berrypod/lib/berrypod_web/live/admin/reviews.ex

390 lines
11 KiB
Elixir
Raw Normal View History

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