complete reviews system (phases 4-6)
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:
jamey
2026-04-01 22:41:27 +01:00
parent 32eb0c6758
commit 6d2d0c9941
26 changed files with 2155 additions and 157 deletions

View 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

View File

@@ -47,10 +47,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
)
end
base = BerrypodWeb.Endpoint.url()
og_url = R.url(R.product(slug))
og_image = og_image_url(all_images)
# Load review aggregates for JSON-LD (SEO)
{avg_rating, review_count} = Reviews.average_rating_for_product(product.id)
seo_reviews = Reviews.list_reviews_for_product(product.id, limit: 5)
page = Pages.get_page("pdp")
is_discontinued = product.status == "discontinued"
@@ -61,7 +64,10 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:og_type, "product")
|> assign(:og_url, og_url)
|> assign(:og_image, og_image)
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|> assign(
:json_ld,
product_json_ld(product, og_url, og_image, avg_rating, review_count, seo_reviews)
)
|> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:quantity, 1)
@@ -137,26 +143,23 @@ defmodule BerrypodWeb.Shop.Pages.Product do
def handle_event("request_review", %{"email" => email}, socket) do
product = socket.assigns.product
# Always show the same message to prevent email enumeration attacks.
# Attacker shouldn't be able to confirm whether an email has purchased a product.
generic_success =
{:info, "If you've purchased this product, we've sent a verification link to your email."}
case Reviews.request_review_verification(email, product.id, product.title) do
{:ok, :sent} ->
{:noreply,
assign(
socket,
:review_status,
{:info, "Check your email for a link to leave your review."}
)}
{:noreply, assign(socket, :review_status, generic_success)}
{:error, :no_purchase} ->
{:noreply,
assign(
socket,
:review_status,
{:error, "We couldn't find a matching order for this product."}
)}
# Don't reveal that this email hasn't purchased
{:noreply, assign(socket, :review_status, generic_success)}
{:error, :already_reviewed} ->
# This one is safe to reveal - they already have a public review
{:noreply,
assign(socket, :review_status, {:error, "You've already reviewed this product."})}
assign(socket, :review_status, {:info, "You've already reviewed this product."})}
{:error, _reason} ->
{:noreply,
@@ -164,6 +167,21 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end
end
def handle_event("load_more_reviews", _params, socket) do
product = socket.assigns.product
current_reviews = socket.assigns[:reviews] || []
offset = length(current_reviews)
# Load the next batch
more_reviews =
Reviews.list_reviews_for_product(product.id, limit: 10, offset: offset)
|> Enum.map(&format_review_for_display/1)
all_reviews = current_reviews ++ more_reviews
{:noreply, assign(socket, :reviews, all_reviews)}
end
def handle_event(_event, _params, _socket), do: :cont
# ── Review helpers ───────────────────────────────────────────────────
@@ -333,7 +351,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# ── JSON-LD and meta helpers ─────────────────────────────────────────
defp product_json_ld(product, url, image, _base) do
defp product_json_ld(product, url, image, avg_rating, review_count, reviews) do
category_slug =
if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"),
@@ -358,27 +376,32 @@ defmodule BerrypodWeb.Shop.Pages.Product do
]
|> Enum.reject(&is_nil/1)
product_data =
%{
"@type" => "Product",
"name" => product.title,
"description" => plain_text(product.description),
"image" => Enum.reject([image], &is_nil/1),
"url" => url,
"offers" => %{
"@type" => "Offer",
"price" => format_price(product.cheapest_price),
"priceCurrency" => "GBP",
"availability" =>
if(product.in_stock,
do: "https://schema.org/InStock",
else: "https://schema.org/OutOfStock"
),
"url" => url
}
}
|> maybe_add_rating(avg_rating, review_count)
|> maybe_add_reviews(reviews)
data = %{
"@context" => "https://schema.org",
"@graph" => [
%{
"@type" => "Product",
"name" => product.title,
"description" => plain_text(product.description),
"image" => Enum.reject([image], &is_nil/1),
"url" => url,
"offers" => %{
"@type" => "Offer",
"price" => format_price(product.cheapest_price),
"priceCurrency" => "GBP",
"availability" =>
if(product.in_stock,
do: "https://schema.org/InStock",
else: "https://schema.org/OutOfStock"
),
"url" => url
}
},
product_data,
%{
"@type" => "BreadcrumbList",
"itemListElement" => breadcrumbs
@@ -389,6 +412,53 @@ defmodule BerrypodWeb.Shop.Pages.Product do
Jason.encode!(data, escape: :html_safe)
end
defp maybe_add_rating(product_data, nil, _count), do: product_data
defp maybe_add_rating(product_data, _avg, 0), do: product_data
defp maybe_add_rating(product_data, avg_rating, review_count) do
Map.put(product_data, "aggregateRating", %{
"@type" => "AggregateRating",
"ratingValue" => Decimal.to_string(avg_rating),
"reviewCount" => to_string(review_count),
"bestRating" => "5",
"worstRating" => "1"
})
end
defp maybe_add_reviews(product_data, []), do: product_data
defp maybe_add_reviews(product_data, reviews) do
review_data =
Enum.map(reviews, fn review ->
review_item = %{
"@type" => "Review",
"author" => %{"@type" => "Person", "name" => review.author_name},
"datePublished" => Date.to_iso8601(DateTime.to_date(review.inserted_at)),
"reviewRating" => %{
"@type" => "Rating",
"ratingValue" => to_string(review.rating),
"bestRating" => "5",
"worstRating" => "1"
}
}
review_item =
if review.body && review.body != "" do
Map.put(review_item, "reviewBody", review.body)
else
review_item
end
if review.title && review.title != "" do
Map.put(review_item, "name", review.title)
else
review_item
end
end)
Map.put(product_data, "review", review_data)
end
defp format_price(pence) when is_integer(pence) do
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
end
@@ -417,4 +487,31 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> Kernel.<>("")
end
end
defp format_review_for_display(review) do
%{
id: review.id,
rating: review.rating,
title: review.title,
body: review.body,
author: review.author_name,
date: relative_time(review.inserted_at),
verified: not is_nil(review.order_id),
images: Reviews.get_review_images(review)
}
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)} minutes ago"
diff < 86400 -> "#{div(diff, 3600)} hours ago"
diff < 604_800 -> "#{div(diff, 86400)} days ago"
diff < 2_592_000 -> "#{div(diff, 604_800)} weeks ago"
true -> "#{div(diff, 2_592_000)} months ago"
end
end
end

View File

@@ -8,9 +8,12 @@ defmodule BerrypodWeb.Shop.ReviewForm do
use BerrypodWeb, :live_view
alias Berrypod.{Products, Reviews}
alias Berrypod.{Media, Products, Reviews}
alias Berrypod.Reviews.Review
@max_photos 3
@max_file_size 10_000_000
@impl true
def mount(_params, session, socket) do
email_session = session["email_session"]
@@ -24,6 +27,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
|> assign(:form, nil)
|> assign(:submitted, false)
|> assign(:error, nil)
|> assign(:existing_images, [])
|> assign(:removed_image_ids, [])
|> allow_upload(:photos,
accept: ~w(.jpg .jpeg .png .webp .heic),
max_entries: @max_photos,
max_file_size: @max_file_size
)
{:ok, socket}
end
@@ -98,11 +108,15 @@ defmodule BerrypodWeb.Shop.ReviewForm do
changeset = Review.update_changeset(review, %{})
# Load existing images for display
existing_images = Reviews.get_review_images(review)
socket
|> assign(:review, review)
|> assign(:product, product)
|> assign(:form, to_form(changeset))
|> assign(:page_title, "Edit your review")
|> assign(:existing_images, existing_images)
else
assign(socket, :error, "You don't have permission to edit this review.")
end
@@ -144,11 +158,30 @@ defmodule BerrypodWeb.Shop.ReviewForm do
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("cancel_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :photos, ref)}
end
def handle_event("remove_image", %{"id" => image_id}, socket) do
removed = [image_id | socket.assigns.removed_image_ids]
existing = Enum.reject(socket.assigns.existing_images, &(&1.id == image_id))
{:noreply,
socket
|> assign(:removed_image_ids, removed)
|> assign(:existing_images, existing)}
end
defp create_review(socket, params) do
# Consume uploads at submit time - they're ready since auto_upload: true
# means files are uploaded as soon as selected
image_ids = consume_photo_uploads(socket)
params =
params
|> Map.put("product_id", socket.assigns.product.id)
|> Map.put("email", socket.assigns.email)
|> Map.put("image_ids", image_ids)
case Reviews.create_review(params) do
{:ok, _review} ->
@@ -163,6 +196,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end
defp update_review(socket, params) do
# Consume uploads at submit time
new_image_ids = consume_photo_uploads(socket)
# Keep existing images that weren't removed, plus new uploads
kept_ids =
socket.assigns.existing_images
|> Enum.map(& &1.id)
|> Enum.reject(&(&1 in socket.assigns.removed_image_ids))
all_image_ids = kept_ids ++ new_image_ids
params = Map.put(params, "image_ids", all_image_ids)
case Reviews.update_review(socket.assigns.review, params) do
{:ok, _review} ->
{:noreply,
@@ -175,6 +221,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
end
end
# Consume uploaded photos and save them to the database.
# Returns a list of image IDs for the successfully saved images.
# Uses async variant to avoid blocking - image processing happens in Oban.
defp consume_photo_uploads(socket) do
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
case Media.upload_from_entry_async(path, entry, "review") do
{:ok, image} -> {:ok, image.id}
{:error, _reason} -> {:ok, nil}
end
end)
|> Enum.reject(&is_nil/1)
end
@impl true
def render(assigns) do
~H"""
@@ -185,7 +244,13 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<% @error -> %>
<.error_message error={@error} product={@product} review={@review} />
<% @form -> %>
<.review_form form={@form} product={@product} review={@review} />
<.review_form
form={@form}
product={@product}
review={@review}
uploads={@uploads}
existing_images={@existing_images}
/>
<% true -> %>
<p>Loading...</p>
<% end %>
@@ -223,23 +288,19 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<div class="review-error">
<h1>Unable to leave review</h1>
<p>{@error}</p>
<%= if @product do %>
<.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title}
</.link>
<%= if @review do %>
<.link
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
class="review-edit-link"
>
Edit your existing review
</.link>
<% end %>
<% else %>
<.link href={BerrypodWeb.R.home()} class="review-back-link">
Back to shop
</.link>
<% end %>
<.link :if={@product} href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title}
</.link>
<.link
:if={@product && @review}
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
class="review-edit-link"
>
Edit your existing review
</.link>
<.link :if={!@product} href={BerrypodWeb.R.home()} class="review-back-link">
Back to shop
</.link>
</div>
"""
end
@@ -255,6 +316,7 @@ defmodule BerrypodWeb.Shop.ReviewForm do
<.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
<div class="review-form-field">
<label class="review-form-label">Rating</label>
<input type="hidden" name="review[rating]" value={@form[:rating].value} />
<.star_rating_input rating={@form[:rating].value} />
<p
:for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)}
@@ -322,6 +384,8 @@ defmodule BerrypodWeb.Shop.ReviewForm do
</p>
</div>
<.photo_upload_section uploads={@uploads} existing_images={@existing_images} />
<button type="submit" class="review-form-submit">
{if @review, do: "Update review", else: "Submit review"}
</button>
@@ -330,28 +394,140 @@ defmodule BerrypodWeb.Shop.ReviewForm do
"""
end
defp star_rating_input(assigns) do
rating = assigns.rating
rating = if is_binary(rating), do: String.to_integer(rating), else: rating
defp photo_upload_section(assigns) do
# Count total photos (existing + pending uploads)
total_photos = length(assigns.existing_images) + length(assigns.uploads.photos.entries)
can_add_more = total_photos < 3
assigns = assign(assigns, :rating, rating)
assigns = assign(assigns, :can_add_more, can_add_more)
~H"""
<div class="star-rating-input">
<%= for i <- 1..5 do %>
<button
type="button"
phx-click="set_rating"
phx-value-rating={i}
class={"star-rating-btn #{if @rating && @rating >= i, do: "star-filled", else: "star-empty"}"}
aria-label={"Rate #{i} stars"}
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
<div class="review-form-field">
<label class="review-form-label">
Photos <span class="review-form-optional">(optional)</span>
</label>
<div class="review-photo-previews">
<%!-- Existing images (from editing a review) --%>
<div :for={image <- @existing_images} class="review-photo-preview">
<img src={image_thumb_url(image)} alt="Review photo" />
<button
type="button"
class="review-photo-remove"
phx-click="remove_image"
phx-value-id={image.id}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<%!-- Pending upload entries (uploaded to server, consumed on submit) --%>
<div :for={entry <- @uploads.photos.entries} class="review-photo-preview">
<.live_img_preview entry={entry} />
<button
type="button"
class="review-photo-remove"
phx-click="cancel_upload"
phx-value-ref={entry.ref}
aria-label="Remove photo"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div :if={entry.progress > 0 and entry.progress < 100} class="review-photo-progress">
<div class="review-photo-progress-bar" style={"width: #{entry.progress}%"}></div>
</div>
</div>
<.live_file_input upload={@uploads.photos} class="sr-only" />
<label :if={@can_add_more} class="review-photo-add" for={@uploads.photos.ref}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</button>
<% end %>
<span>Add photo</span>
</label>
</div>
<p
:for={entry <- @uploads.photos.entries}
:if={entry.client_type not in ~w(image/jpeg image/png image/webp image/heic)}
class="review-form-error"
>
{entry.client_name}: not a supported image type
</p>
<p :for={err <- upload_errors(@uploads.photos)} class="review-form-error">
{upload_error_to_string(err)}
</p>
<p class="review-form-hint">You can add up to 3 photos (max 10MB each)</p>
</div>
"""
end
defp upload_error_to_string(:too_large), do: "File is too large (max 10MB)"
defp upload_error_to_string(:too_many_files), do: "Too many files (max 3)"
defp upload_error_to_string(:not_accepted), do: "File type not supported"
defp upload_error_to_string(_), do: "Upload error"
defp image_thumb_url(image) do
if image.variants_status == "complete" do
"/image_cache/#{image.id}-thumb.jpg"
else
"/images/#{image.id}"
end
end
defp star_rating_input(assigns) do
assigns = assign(assigns, :rating, parse_rating(assigns.rating))
~H"""
<div class="star-rating-input">
<button
:for={i <- 1..5}
type="button"
phx-click="set_rating"
phx-value-rating={i}
class={["star-rating-btn", if(@rating && @rating >= i, do: "star-filled", else: "star-empty")]}
aria-label={"Rate #{i} stars"}
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</div>
"""
end
defp parse_rating(""), do: nil
defp parse_rating(s) when is_binary(s), do: String.to_integer(s)
defp parse_rating(rating), do: rating
end