complete reviews system (phases 4-6)
All checks were successful
deploy / deploy (push) Successful in 1m4s
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:
@@ -69,6 +69,10 @@ defmodule Berrypod.Images.Optimizer do
|
||||
@doc """
|
||||
Process image and generate all applicable variants.
|
||||
Called by Oban worker.
|
||||
|
||||
Handles both:
|
||||
- Pre-converted images (with source_width set)
|
||||
- Raw uploaded images (need conversion to WebP first)
|
||||
"""
|
||||
def process_for_image(image_id) do
|
||||
# Load the image row and release the DB connection immediately,
|
||||
@@ -89,36 +93,73 @@ defmodule Berrypod.Images.Optimizer do
|
||||
%{data: data} when byte_size(data) < @min_image_bytes ->
|
||||
{:error, :too_small}
|
||||
|
||||
%{data: data, source_width: nil} ->
|
||||
# Raw image that needs conversion first
|
||||
process_raw_image(image, data)
|
||||
|
||||
%{data: data, source_width: width} ->
|
||||
File.mkdir_p!(cache_dir())
|
||||
# Already converted to WebP with dimensions
|
||||
process_converted_image(image, data, width)
|
||||
end
|
||||
end
|
||||
|
||||
# Write source WebP to disk so it can be served by Plug.Static
|
||||
source_path = Path.join(cache_dir(), "#{image_id}.webp")
|
||||
unless File.exists?(source_path), do: File.write!(source_path, data)
|
||||
|
||||
with {:ok, vips_image} <- Image.from_binary(data) do
|
||||
widths = applicable_widths(width)
|
||||
|
||||
all_tasks =
|
||||
[fn -> generate_thumbnail(vips_image, image_id) end] ++
|
||||
for w <- widths, fmt <- @pregenerated_formats do
|
||||
fn -> generate_variant(vips_image, image_id, w, fmt) end
|
||||
end
|
||||
|
||||
# Cap concurrency to the number of CPU cores — keeps small
|
||||
# machines from choking while still saturating bigger ones.
|
||||
Task.async_stream(all_tasks, & &1.(),
|
||||
max_concurrency: System.schedulers_online(),
|
||||
timeout: :timer.seconds(120)
|
||||
# Process a raw uploaded image: convert to WebP, update DB, then generate variants
|
||||
defp process_raw_image(image, data) do
|
||||
case to_optimized_webp(data) do
|
||||
{:ok, webp_data, width, height} ->
|
||||
# Update the image record with converted data and dimensions
|
||||
updated =
|
||||
Repo.update!(
|
||||
ImageSchema.changeset(image, %{
|
||||
data: webp_data,
|
||||
content_type: "image/webp",
|
||||
file_size: byte_size(webp_data),
|
||||
source_width: width,
|
||||
source_height: height
|
||||
})
|
||||
)
|
||||
|> Stream.run()
|
||||
|
||||
# Extract dominant colors for header images (used for contrast checking)
|
||||
maybe_extract_dominant_colors(image, vips_image)
|
||||
# Now generate variants with the converted data
|
||||
process_converted_image(updated, webp_data, width)
|
||||
|
||||
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
|
||||
{:ok, widths}
|
||||
end
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to convert image #{image.id}: #{inspect(reason)}")
|
||||
# Mark as complete anyway so we don't retry forever
|
||||
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Process an already-converted WebP image: generate variants
|
||||
defp process_converted_image(image, data, width) do
|
||||
File.mkdir_p!(cache_dir())
|
||||
|
||||
# Write source WebP to disk so it can be served by Plug.Static
|
||||
source_path = Path.join(cache_dir(), "#{image.id}.webp")
|
||||
unless File.exists?(source_path), do: File.write!(source_path, data)
|
||||
|
||||
with {:ok, vips_image} <- Image.from_binary(data) do
|
||||
widths = applicable_widths(width)
|
||||
|
||||
all_tasks =
|
||||
[fn -> generate_thumbnail(vips_image, image.id) end] ++
|
||||
for w <- widths, fmt <- @pregenerated_formats do
|
||||
fn -> generate_variant(vips_image, image.id, w, fmt) end
|
||||
end
|
||||
|
||||
# Cap concurrency to the number of CPU cores — keeps small
|
||||
# machines from choking while still saturating bigger ones.
|
||||
Task.async_stream(all_tasks, & &1.(),
|
||||
max_concurrency: System.schedulers_online(),
|
||||
timeout: :timer.seconds(120)
|
||||
)
|
||||
|> Stream.run()
|
||||
|
||||
# Extract dominant colors for header images (used for contrast checking)
|
||||
maybe_extract_dominant_colors(image, vips_image)
|
||||
|
||||
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
|
||||
{:ok, widths}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -118,6 +118,43 @@ defmodule Berrypod.Media do
|
||||
upload_image(Map.merge(base, extra_attrs))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Upload that stores raw image data without synchronous processing.
|
||||
|
||||
Unlike `upload_from_entry/4`, this skips the WebP conversion step and
|
||||
defers all image processing to the background Oban worker. This is
|
||||
essential for multi-file uploads where synchronous processing would
|
||||
cause upload channel timeouts.
|
||||
|
||||
The trade-off: images won't have `source_width`/`source_height` set
|
||||
until the background job runs, and won't display optimized variants
|
||||
immediately. Fine for review photos where we show a `live_img_preview`
|
||||
during upload and the final display can wait.
|
||||
"""
|
||||
def upload_from_entry_async(path, entry, image_type) do
|
||||
file_binary = File.read!(path)
|
||||
|
||||
attrs = %{
|
||||
image_type: image_type,
|
||||
filename: entry.client_name,
|
||||
content_type: entry.client_type,
|
||||
file_size: byte_size(file_binary),
|
||||
data: file_binary,
|
||||
variants_status: "pending"
|
||||
}
|
||||
|
||||
case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
|
||||
{:ok, image} ->
|
||||
# Enqueue background job for conversion and optimization
|
||||
OptimizeWorker.enqueue(image.id)
|
||||
invalidate_media_cache(image.image_type)
|
||||
{:ok, image}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single image by ID.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ defmodule Berrypod.Media.Image do
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@max_file_size 5_000_000
|
||||
@max_file_size 10_000_000
|
||||
|
||||
@doc false
|
||||
def changeset(image, attrs) do
|
||||
@@ -46,7 +46,7 @@ defmodule Berrypod.Media.Image do
|
||||
:dominant_colors
|
||||
])
|
||||
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|
||||
|> validate_inclusion(:image_type, ~w(logo header product icon media))
|
||||
|> validate_inclusion(:image_type, ~w(logo header product icon media review))
|
||||
|> validate_number(:file_size, less_than: @max_file_size)
|
||||
|> detect_svg()
|
||||
end
|
||||
|
||||
@@ -272,6 +272,10 @@ defmodule Berrypod.Orders do
|
||||
OrderNotifier.deliver_shipping_notification(updated_order)
|
||||
end
|
||||
|
||||
if attrs[:fulfilment_status] == "delivered" and order.fulfilment_status != "delivered" do
|
||||
Berrypod.Reviews.ReviewRequestWorker.enqueue(updated_order.id)
|
||||
end
|
||||
|
||||
{:ok, updated_order}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -490,7 +490,8 @@ defmodule Berrypod.Pages.BlockTypes do
|
||||
body: review.body,
|
||||
author: review.author_name,
|
||||
date: relative_time(review.inserted_at),
|
||||
verified: not is_nil(review.order_id)
|
||||
verified: not is_nil(review.order_id),
|
||||
images: Berrypod.Reviews.get_review_images(review)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ defmodule Berrypod.Products.Product do
|
||||
field :in_stock, :boolean, default: true
|
||||
field :on_sale, :boolean, default: false
|
||||
|
||||
# Denormalized from reviews — recomputed by Reviews.update_product_rating_cache/1
|
||||
field :rating_avg, :decimal
|
||||
field :rating_count, :integer, default: 0
|
||||
|
||||
belongs_to :provider_connection, Berrypod.Products.ProviderConnection
|
||||
has_many :images, Berrypod.Products.ProductImage
|
||||
has_many :variants, Berrypod.Products.ProductVariant
|
||||
|
||||
@@ -44,10 +44,16 @@ defmodule Berrypod.Reviews do
|
||||
|
||||
@doc """
|
||||
Lists approved reviews for a product, newest first.
|
||||
|
||||
Options:
|
||||
* `:status` - filter by status (default "approved")
|
||||
* `:limit` - max number of reviews to return
|
||||
* `:offset` - number of reviews to skip (for pagination)
|
||||
"""
|
||||
def list_reviews_for_product(product_id, opts \\ []) do
|
||||
status = Keyword.get(opts, :status, "approved")
|
||||
limit = Keyword.get(opts, :limit)
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
|
||||
query =
|
||||
from r in Review,
|
||||
@@ -55,6 +61,7 @@ defmodule Berrypod.Reviews do
|
||||
order_by: [desc: r.inserted_at]
|
||||
|
||||
query = if limit, do: limit(query, ^limit), else: query
|
||||
query = if offset > 0, do: offset(query, ^offset), else: query
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
@@ -94,6 +101,57 @@ defmodule Berrypod.Reviews do
|
||||
Repo.one(from r in Review, where: r.status == "pending", select: count(r.id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns counts of reviews by status for admin tabs.
|
||||
"""
|
||||
def count_reviews_by_status do
|
||||
Repo.all(
|
||||
from r in Review,
|
||||
group_by: r.status,
|
||||
select: {r.status, count(r.id)}
|
||||
)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists reviews with pagination for admin.
|
||||
|
||||
Options:
|
||||
* `:status` - filter by status (nil for all)
|
||||
* `:search` - search email or product title
|
||||
* `:page` - page number
|
||||
* `:per_page` - items per page (default 20)
|
||||
"""
|
||||
def list_reviews_paginated(opts \\ []) do
|
||||
status = Keyword.get(opts, :status)
|
||||
search = Keyword.get(opts, :search)
|
||||
|
||||
query =
|
||||
from r in Review,
|
||||
join: p in assoc(r, :product),
|
||||
order_by: [desc: r.inserted_at],
|
||||
preload: [product: p]
|
||||
|
||||
query = if status && status != "", do: where(query, [r], r.status == ^status), else: query
|
||||
|
||||
query =
|
||||
if search && search != "" do
|
||||
pattern = "%#{String.downcase(search)}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[r, p],
|
||||
like(fragment("lower(?)", r.email), ^pattern) or
|
||||
like(fragment("lower(?)", r.author_name), ^pattern) or
|
||||
like(fragment("lower(?)", p.title), ^pattern)
|
||||
)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 20)
|
||||
end
|
||||
|
||||
# ── Aggregates ─────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
@@ -169,7 +227,15 @@ defmodule Berrypod.Reviews do
|
||||
|
||||
# Auto-link to matching order for "verified purchase" badge
|
||||
order = if email && product_id, do: find_matching_order(email, product_id), else: nil
|
||||
attrs = if order, do: Map.put(attrs, :order_id, order.id), else: attrs
|
||||
|
||||
attrs =
|
||||
if order do
|
||||
# Use same key type as the input map
|
||||
key = if Map.has_key?(attrs, :email), do: :order_id, else: "order_id"
|
||||
Map.put(attrs, key, order.id)
|
||||
else
|
||||
attrs
|
||||
end
|
||||
|
||||
%Review{}
|
||||
|> Review.changeset(attrs)
|
||||
@@ -188,27 +254,64 @@ defmodule Berrypod.Reviews do
|
||||
|
||||
@doc """
|
||||
Approves a review for public display.
|
||||
Updates the product's rating cache.
|
||||
"""
|
||||
def approve_review(%Review{} = review) do
|
||||
review
|
||||
|> Review.moderation_changeset("approved")
|
||||
|> Repo.update()
|
||||
result =
|
||||
review
|
||||
|> Review.moderation_changeset("approved")
|
||||
|> Repo.update()
|
||||
|
||||
case result do
|
||||
{:ok, updated} ->
|
||||
update_product_rating_cache(updated.product_id)
|
||||
{:ok, updated}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rejects a review (won't be displayed).
|
||||
Updates the product's rating cache if the review was previously approved.
|
||||
"""
|
||||
def reject_review(%Review{} = review) do
|
||||
review
|
||||
|> Review.moderation_changeset("rejected")
|
||||
|> Repo.update()
|
||||
was_approved = review.status == "approved"
|
||||
|
||||
result =
|
||||
review
|
||||
|> Review.moderation_changeset("rejected")
|
||||
|> Repo.update()
|
||||
|
||||
case result do
|
||||
{:ok, updated} ->
|
||||
if was_approved, do: update_product_rating_cache(updated.product_id)
|
||||
{:ok, updated}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a review.
|
||||
Updates the product's rating cache if the review was approved.
|
||||
"""
|
||||
def delete_review(%Review{} = review) do
|
||||
Repo.delete(review)
|
||||
was_approved = review.status == "approved"
|
||||
product_id = review.product_id
|
||||
|
||||
result = Repo.delete(review)
|
||||
|
||||
case result do
|
||||
{:ok, _} ->
|
||||
if was_approved, do: update_product_rating_cache(product_id)
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# ── Images ─────────────────────────────────────────────────────────
|
||||
@@ -223,6 +326,35 @@ defmodule Berrypod.Reviews do
|
||||
|
||||
def get_review_images(_), do: []
|
||||
|
||||
@doc """
|
||||
Batch loads images for multiple reviews, returning a map of review_id => [images].
|
||||
More efficient than calling get_review_images/1 for each review.
|
||||
"""
|
||||
def preload_review_images(reviews) when is_list(reviews) do
|
||||
# Collect all image IDs across all reviews
|
||||
all_image_ids =
|
||||
reviews
|
||||
|> Enum.flat_map(fn r -> r.image_ids || [] end)
|
||||
|> Enum.uniq()
|
||||
|
||||
if all_image_ids == [] do
|
||||
Map.new(reviews, fn r -> {r.id, []} end)
|
||||
else
|
||||
# Fetch all images in one query
|
||||
images_by_id = Media.get_images(all_image_ids) |> Map.new(&{&1.id, &1})
|
||||
|
||||
# Build map of review_id => [images]
|
||||
Map.new(reviews, fn review ->
|
||||
images =
|
||||
(review.image_ids || [])
|
||||
|> Enum.map(&Map.get(images_by_id, &1))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
{review.id, images}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Review tokens ──────────────────────────────────────────────────
|
||||
|
||||
@review_salt "review_verification_v1"
|
||||
@@ -293,6 +425,25 @@ defmodule Berrypod.Reviews do
|
||||
end
|
||||
end
|
||||
|
||||
# ── Rating cache ──────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Updates the cached rating_avg and rating_count on a product.
|
||||
|
||||
Called after review approval, rejection, or deletion to keep the
|
||||
denormalized values in sync. Uses approved reviews only.
|
||||
"""
|
||||
def update_product_rating_cache(product_id) do
|
||||
{avg, count} = average_rating_for_product(product_id)
|
||||
|
||||
Repo.update_all(
|
||||
from(p in Berrypod.Products.Product, where: p.id == ^product_id),
|
||||
set: [rating_avg: avg, rating_count: count]
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp normalise_email(email) when is_binary(email) do
|
||||
|
||||
@@ -39,6 +39,52 @@ defmodule Berrypod.Reviews.ReviewNotifier do
|
||||
deliver(email, subject, body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a review request email after order delivery.
|
||||
|
||||
Includes links to review each product in the order that hasn't been
|
||||
reviewed yet. Each link contains a signed token for that email+product.
|
||||
"""
|
||||
def deliver_review_request(order, unreviewable_products) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
customer_name = extract_customer_name(order)
|
||||
|
||||
product_links =
|
||||
unreviewable_products
|
||||
|> Enum.map(fn %{product_id: product_id, product_name: name} ->
|
||||
token = Berrypod.Reviews.generate_review_token(order.customer_email, product_id)
|
||||
link = BerrypodWeb.Endpoint.url() <> "/reviews/new?token=#{token}"
|
||||
" #{name}\n #{link}"
|
||||
end)
|
||||
|> Enum.join("\n\n")
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Hi#{if customer_name, do: " #{customer_name}", else: ""},
|
||||
|
||||
Your order #{order.order_number} was delivered recently. We'd love to hear what you think!
|
||||
|
||||
#{product_links}
|
||||
|
||||
Thanks for shopping with us.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(
|
||||
order.customer_email,
|
||||
"How was your order from #{shop_name}?",
|
||||
body
|
||||
)
|
||||
end
|
||||
|
||||
defp extract_customer_name(%{shipping_address: %{"name" => name}}) when is_binary(name) do
|
||||
name |> String.split(" ") |> List.first()
|
||||
end
|
||||
|
||||
defp extract_customer_name(_), do: nil
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
|
||||
108
lib/berrypod/reviews/review_request_worker.ex
Normal file
108
lib/berrypod/reviews/review_request_worker.ex
Normal file
@@ -0,0 +1,108 @@
|
||||
defmodule Berrypod.Reviews.ReviewRequestWorker do
|
||||
@moduledoc """
|
||||
Sends review request emails after order delivery.
|
||||
|
||||
Enqueued with a configurable delay (default 7 days) after an order is marked
|
||||
as delivered. At send time, checks which products in the order haven't been
|
||||
reviewed yet and sends a single email with links for each.
|
||||
|
||||
Only one review request is sent per order. If the customer has already
|
||||
reviewed all products, no email is sent.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :mailer, max_attempts: 3
|
||||
|
||||
alias Berrypod.{Orders, Reviews, Settings}
|
||||
alias Berrypod.Reviews.ReviewNotifier
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"order_id" => order_id}}) do
|
||||
case Orders.get_order(order_id) do
|
||||
nil ->
|
||||
Logger.warning("Review request: order #{order_id} not found")
|
||||
{:cancel, :not_found}
|
||||
|
||||
%{customer_email: nil} ->
|
||||
Logger.info("Review request: no customer email for order #{order_id}")
|
||||
:ok
|
||||
|
||||
%{customer_email: ""} ->
|
||||
Logger.info("Review request: no customer email for order #{order_id}")
|
||||
:ok
|
||||
|
||||
order ->
|
||||
send_review_request(order)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_review_request(order) do
|
||||
# Check suppression - review requests are marketing emails
|
||||
case Orders.check_suppression(order.customer_email) do
|
||||
:suppressed ->
|
||||
Logger.info("Review request: email suppressed for order #{order.order_number}")
|
||||
:ok
|
||||
|
||||
:ok ->
|
||||
do_send_review_request(order)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_send_review_request(order) do
|
||||
# Find products in this order that haven't been reviewed yet
|
||||
unreviewable_products = get_unreviewable_products(order)
|
||||
|
||||
if unreviewable_products == [] do
|
||||
Logger.info("Review request: all products already reviewed for order #{order.order_number}")
|
||||
:ok
|
||||
else
|
||||
case ReviewNotifier.deliver_review_request(order, unreviewable_products) do
|
||||
{:ok, _} ->
|
||||
Logger.info(
|
||||
"Review request sent to #{order.customer_email} for order #{order.order_number}"
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Review request failed for order #{order.order_number}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_unreviewable_products(order) do
|
||||
order.items
|
||||
|> Enum.map(& &1.product_id)
|
||||
|> Enum.uniq()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.reject(fn product_id ->
|
||||
# Already reviewed?
|
||||
not is_nil(Reviews.get_review_by_email_and_product(order.customer_email, product_id))
|
||||
end)
|
||||
|> Enum.map(fn product_id ->
|
||||
# Find the item details for this product
|
||||
item = Enum.find(order.items, &(&1.product_id == product_id))
|
||||
%{product_id: product_id, product_name: item.product_name}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enqueues a review request job for an order.
|
||||
|
||||
Called when an order is marked as delivered. Schedules the job based on
|
||||
the `review_request_delay_days` setting (default 7 days).
|
||||
"""
|
||||
def enqueue(order_id) do
|
||||
delay_days = Settings.get_setting("review_request_delay_days", 7)
|
||||
delay_seconds = delay_days * 24 * 60 * 60
|
||||
|
||||
%{order_id: order_id}
|
||||
|> new(schedule_in: delay_seconds)
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
"""
|
||||
import Phoenix.Component
|
||||
|
||||
alias Berrypod.{ActivityLog, Settings}
|
||||
alias Berrypod.{ActivityLog, Reviews, Settings}
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def on_mount(:assign_current_path, _params, _session, socket) do
|
||||
@@ -33,6 +33,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
|> assign(:site_description, Settings.site_description())
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:attention_count, ActivityLog.count_needing_attention())
|
||||
|> assign(:pending_review_count, Reviews.count_pending_reviews())
|
||||
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
|
||||
uri,
|
||||
socket ->
|
||||
|
||||
@@ -126,6 +126,20 @@
|
||||
<.icon name="hero-photo" class="size-5" /> Media
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/reviews"}
|
||||
class={admin_nav_active?(@current_path, "/admin/reviews")}
|
||||
>
|
||||
<.icon name="hero-star" class="size-5" /> Reviews
|
||||
<span
|
||||
:if={@pending_review_count > 0}
|
||||
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
|
||||
>
|
||||
{@pending_review_count}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter"}
|
||||
|
||||
@@ -951,18 +951,189 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a generic image lightbox dialog.
|
||||
|
||||
Reusable by product galleries, review photos, or any image gallery.
|
||||
Uses the Lightbox JS hook for keyboard navigation and modal behavior.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `id` - Required. Unique ID for the dialog element.
|
||||
* `images` - Required. List of image URLs.
|
||||
* `caption` - Optional. Caption text shown below the image.
|
||||
|
||||
## Examples
|
||||
|
||||
<.image_lightbox id="review-123-lightbox" images={@image_urls} caption="Customer photo" />
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :images, :list, required: true
|
||||
attr :caption, :string, default: nil
|
||||
|
||||
def image_lightbox(assigns) do
|
||||
~H"""
|
||||
<dialog
|
||||
class="lightbox"
|
||||
id={@id}
|
||||
aria-label="Image gallery"
|
||||
data-current-index="0"
|
||||
data-images={Jason.encode!(@images)}
|
||||
data-show={Phoenix.LiveView.JS.exec("phx-show-lightbox", to: "##{@id}")}
|
||||
phx-show-lightbox={Phoenix.LiveView.JS.dispatch("lightbox:open", to: "##{@id}")}
|
||||
phx-hook="Lightbox"
|
||||
>
|
||||
<div class="lightbox-content">
|
||||
<button
|
||||
type="button"
|
||||
class="lightbox-close"
|
||||
aria-label="Close gallery"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:close", to: "##{@id}")}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
class="lightbox-nav lightbox-prev"
|
||||
aria-label="Previous image"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:prev", to: "##{@id}")}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<figure class="lightbox-figure">
|
||||
<div class="lightbox-image-container">
|
||||
<img
|
||||
class="lightbox-image"
|
||||
src={List.first(@images)}
|
||||
alt={@caption || "Image"}
|
||||
width="1200"
|
||||
height="1200"
|
||||
/>
|
||||
</div>
|
||||
<figcaption :if={@caption} class="lightbox-caption">{@caption}</figcaption>
|
||||
</figure>
|
||||
<button
|
||||
type="button"
|
||||
class="lightbox-nav lightbox-next"
|
||||
aria-label="Next image"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("lightbox:next", to: "##{@id}")}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div :if={length(@images) > 1} class="lightbox-counter">1 / {length(@images)}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders review photo thumbnails that open in a lightbox.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `images` - Required. List of Image structs with `id` and `variants_status`.
|
||||
* `review_id` - Required. ID of the review (for unique lightbox ID).
|
||||
|
||||
## Examples
|
||||
|
||||
<.review_photos images={@review.images} review_id={@review.id} />
|
||||
"""
|
||||
attr :images, :list, required: true
|
||||
attr :review_id, :string, required: true
|
||||
|
||||
def review_photos(assigns) do
|
||||
# Build URLs from image structs
|
||||
thumb_urls =
|
||||
Enum.map(assigns.images, fn img ->
|
||||
if img.variants_status == "complete" do
|
||||
"/image_cache/#{img.id}-thumb.jpg"
|
||||
else
|
||||
"/images/#{img.id}"
|
||||
end
|
||||
end)
|
||||
|
||||
full_urls =
|
||||
Enum.map(assigns.images, fn img ->
|
||||
if img.variants_status == "complete" do
|
||||
"/image_cache/#{img.id}-800.webp"
|
||||
else
|
||||
"/images/#{img.id}"
|
||||
end
|
||||
end)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:thumb_urls, thumb_urls)
|
||||
|> assign(:full_urls, full_urls)
|
||||
|
||||
~H"""
|
||||
<div :if={@images != []} class="review-photos">
|
||||
<button
|
||||
:for={{thumb, idx} <- Enum.with_index(@thumb_urls)}
|
||||
type="button"
|
||||
class="review-photo-thumb"
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.exec("data-show", to: "#review-#{@review_id}-lightbox")
|
||||
|> Phoenix.LiveView.JS.set_attribute(
|
||||
{"data-current-index", to_string(idx)},
|
||||
to: "#review-#{@review_id}-lightbox"
|
||||
)
|
||||
}
|
||||
>
|
||||
<img src={thumb} alt="Customer photo" loading="lazy" />
|
||||
</button>
|
||||
|
||||
<.image_lightbox
|
||||
id={"review-#{@review_id}-lightbox"}
|
||||
images={@full_urls}
|
||||
caption="Customer photo"
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a customer reviews section with collapsible header and review cards.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `reviews` - Required. List of review maps with:
|
||||
- `id` - Review ID
|
||||
- `rating` - Star rating (1-5)
|
||||
- `title` - Review title
|
||||
- `body` - Review text
|
||||
- `author` - Reviewer name
|
||||
- `date` - Relative date string (e.g., "2 weeks ago")
|
||||
- `verified` - Boolean, if true shows "Verified purchase" badge
|
||||
- `images` - Optional. List of Image structs for review photos.
|
||||
* `average_rating` - Optional. Average rating to show in header. Defaults to nil.
|
||||
* `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
|
||||
* `open` - Optional. Whether section is expanded by default. Defaults to true.
|
||||
@@ -1038,7 +1209,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.shop_button_outline :if={length(@reviews) < @display_count} class="reviews-load-more">
|
||||
<.shop_button_outline
|
||||
:if={length(@reviews) < @display_count}
|
||||
class="reviews-load-more"
|
||||
phx-click="load_more_reviews"
|
||||
>
|
||||
Load more reviews
|
||||
</.shop_button_outline>
|
||||
<% else %>
|
||||
@@ -1121,7 +1296,37 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
~H"""
|
||||
<div class={"review-status review-status-#{@type}"}>
|
||||
{@message}
|
||||
<svg
|
||||
:if={@type == :info}
|
||||
class="review-status-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@type == :error}
|
||||
class="review-status-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
<p>{@message}</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -1131,11 +1336,11 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
## Attributes
|
||||
|
||||
* `review` - Required. Map with `rating`, `title`, `body`, `author`, `date`, `verified`.
|
||||
* `review` - Required. Map with `id`, `rating`, `title`, `body`, `author`, `date`, `verified`, and optional `images`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.review_card review={%{rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true}} />
|
||||
<.review_card review={%{id: "abc", rating: 5, title: "Great!", body: "...", author: "Jane", date: "1 week ago", verified: true, images: []}} />
|
||||
"""
|
||||
attr :review, :map, required: true
|
||||
|
||||
@@ -1146,19 +1351,33 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<.star_rating rating={@review.rating} />
|
||||
<span class="review-date">{@review.date}</span>
|
||||
</div>
|
||||
<h3 class="review-title">{@review.title}</h3>
|
||||
<p class="review-body">
|
||||
<h3 :if={@review.title} class="review-title">{@review.title}</h3>
|
||||
<p :if={@review.body} class="review-body">
|
||||
{@review.body}
|
||||
</p>
|
||||
<.review_photos
|
||||
:if={@review[:images] && @review.images != []}
|
||||
images={@review.images}
|
||||
review_id={@review.id}
|
||||
/>
|
||||
<div class="review-footer">
|
||||
<span class="review-author">
|
||||
{@review.author}
|
||||
</span>
|
||||
<%= if @review.verified do %>
|
||||
<span class="review-verified">
|
||||
Verified purchase
|
||||
</span>
|
||||
<% end %>
|
||||
<span :if={@review.verified} class="review-verified">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
Verified purchase
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
use Phoenix.Component
|
||||
|
||||
import BerrypodWeb.ShopComponents.Base
|
||||
import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1]
|
||||
import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1, star_rating: 1]
|
||||
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
alias BerrypodWeb.R
|
||||
@@ -191,6 +191,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<%= if @theme_settings.show_prices do %>
|
||||
<.product_price product={@product} variant={@variant} />
|
||||
<% end %>
|
||||
<.product_card_rating product={@product} />
|
||||
<%= if @show_delivery_text do %>
|
||||
<p class="product-card-delivery">
|
||||
Made to order
|
||||
@@ -356,6 +357,33 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :product, :map, required: true
|
||||
|
||||
defp product_card_rating(assigns) do
|
||||
rating_count = Map.get(assigns.product, :rating_count, 0)
|
||||
rating_avg = Map.get(assigns.product, :rating_avg)
|
||||
|
||||
# Round to nearest integer for stars display
|
||||
rating_rounded =
|
||||
if rating_avg do
|
||||
rating_avg |> Decimal.round(0) |> Decimal.to_integer()
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:rating_count, rating_count)
|
||||
|> assign(:rating_rounded, rating_rounded)
|
||||
|
||||
~H"""
|
||||
<div :if={@rating_count > 0} class="product-card-rating">
|
||||
<.star_rating rating={@rating_rounded} size={:sm} />
|
||||
<span class="product-card-rating-count">({@rating_count})</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp variant_defaults(:default),
|
||||
do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true}
|
||||
|
||||
@@ -1260,7 +1288,6 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<div class="lightbox-image-container">
|
||||
<img
|
||||
class="lightbox-image"
|
||||
id="lightbox-image"
|
||||
src={List.first(@images)}
|
||||
alt={@product_name}
|
||||
width="1200"
|
||||
@@ -1286,7 +1313,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="lightbox-counter" id="lightbox-counter">1 / {length(@images)}</div>
|
||||
<div class="lightbox-counter">1 / {length(@images)}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
"""
|
||||
|
||||
389
lib/berrypod_web/live/admin/reviews.ex
Normal file
389
lib/berrypod_web/live/admin/reviews.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,6 +177,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
|
||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||
live "/media", Admin.Media, :index
|
||||
live "/reviews", Admin.Reviews, :index
|
||||
live "/newsletter", Admin.Newsletter, :index
|
||||
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
||||
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
||||
|
||||
Reference in New Issue
Block a user