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

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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 ->

View File

@@ -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"}

View File

@@ -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>
"""

View File

@@ -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>
"""

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

View File

@@ -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