390 lines
11 KiB
Elixir
390 lines
11 KiB
Elixir
|
|
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
|