add review submission flow (phase 3)
All checks were successful
deploy / deploy (push) Successful in 1m10s

- Add ReviewNotifier for verification emails
- Add review token generation and verification
- Add request_review_verification function
- Update reviews_section component with write-a-review form
- Create ReviewForm LiveView for submitting/editing reviews
- Add /reviews/new and /reviews/:id/edit routes
- Add review buttons to order detail page
- Update block_types to load real review data
- Tests for token and verification functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-04-01 11:12:25 +01:00
parent 8dc17a6f4d
commit 32eb0c6758
12 changed files with 835 additions and 34 deletions

View File

@@ -6,7 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [push_navigate: 2]
alias Berrypod.{Orders, Pages}
alias Berrypod.{Orders, Pages, Reviews}
alias BerrypodWeb.R
alias Berrypod.Products
alias Berrypod.Products.ProductImage
@@ -44,7 +44,16 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
slug = if variant.product.visible, do: variant.product.slug, else: nil
{id, %{thumb: thumb, slug: slug}}
# Check if user has reviewed this product
existing_review = Reviews.get_review_by_email_and_product(email, variant.product_id)
{id,
%{
thumb: thumb,
slug: slug,
product_id: variant.product_id,
existing_review: existing_review
}}
end)
socket =

View File

@@ -6,7 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
import Phoenix.Component, only: [assign: 2, assign: 3]
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages}
alias Berrypod.{Analytics, Cart, Pages, Reviews}
alias BerrypodWeb.R
alias Berrypod.Images.Optimizer
alias Berrypod.Products
@@ -69,6 +69,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|> assign(:variants, variants)
|> assign(:page, page)
|> assign(:product_discontinued, is_discontinued)
|> assign(:review_form, nil)
|> assign(:review_status, nil)
|> assign(:existing_review, nil)
# Check if user has an existing review for this product
socket = load_existing_review(socket)
# Block data loaders (related_products, reviews) run after product is assigned
extra = Pages.load_block_data(page.blocks, socket.assigns)
@@ -126,8 +132,56 @@ defmodule BerrypodWeb.Shop.Pages.Product do
end
end
# ── Review events ──────────────────────────────────────────────────────
def handle_event("request_review", %{"email" => email}, socket) do
product = socket.assigns.product
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."}
)}
{:error, :no_purchase} ->
{:noreply,
assign(
socket,
:review_status,
{:error, "We couldn't find a matching order for this product."}
)}
{:error, :already_reviewed} ->
{:noreply,
assign(socket, :review_status, {:error, "You've already reviewed this product."})}
{:error, _reason} ->
{:noreply,
assign(socket, :review_status, {:error, "Something went wrong. Please try again later."})}
end
end
def handle_event(_event, _params, _socket), do: :cont
# ── Review helpers ───────────────────────────────────────────────────
defp load_existing_review(socket) do
email = socket.assigns[:email_session]
product = socket.assigns.product
if email do
case Reviews.get_review_by_email_and_product(email, product.id) do
nil -> socket
review -> assign(socket, :existing_review, review)
end
else
socket
end
end
# ── Variant selection logic ──────────────────────────────────────────
defp apply_variant_params(params, socket) do

View File

@@ -0,0 +1,357 @@
defmodule BerrypodWeb.Shop.ReviewForm do
@moduledoc """
LiveView for submitting and editing product reviews.
Accessed via /reviews/new?token=xxx for new reviews or
/reviews/:id/edit for editing existing reviews.
"""
use BerrypodWeb, :live_view
alias Berrypod.{Products, Reviews}
alias Berrypod.Reviews.Review
@impl true
def mount(_params, session, socket) do
email_session = session["email_session"]
socket =
socket
|> assign(:email_session, email_session)
|> assign(:page_title, "Write a review")
|> assign(:product, nil)
|> assign(:review, nil)
|> assign(:form, nil)
|> assign(:submitted, false)
|> assign(:error, nil)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, %{"token" => token}) do
case Reviews.verify_review_token(token) do
{:ok, %{email: email, product_id: product_id}} ->
product = Products.get_product(product_id)
if product do
# Check if they've already reviewed
existing = Reviews.get_review_by_email_and_product(email, product_id)
if existing do
socket
|> assign(:error, "You've already reviewed this product.")
|> assign(:review, existing)
|> assign(:product, product)
else
# Get matching order for pre-filling author name
order = Reviews.find_matching_order(email, product_id)
author_name = if order, do: order.shipping_address["name"], else: nil
changeset =
Review.changeset(%Review{}, %{
product_id: product_id,
email: email,
author_name: author_name || "",
rating: nil
})
socket
|> assign(:product, product)
|> assign(:email, email)
|> assign(:form, to_form(changeset))
|> assign(:page_title, "Review #{product.title}")
end
else
assign(socket, :error, "Product not found.")
end
{:error, :invalid} ->
assign(
socket,
:error,
"This link has expired or is invalid. Please request a new one from the product page."
)
end
end
defp apply_action(socket, :new, _params) do
assign(socket, :error, "Invalid review link.")
end
defp apply_action(socket, :edit, %{"id" => id} = params) do
email_session = socket.assigns.email_session
case Reviews.get_review(id) do
nil ->
assign(socket, :error, "Review not found.")
review ->
# Verify ownership via email session
if email_session && String.downcase(email_session) == String.downcase(review.email) do
product = params["product"] && Products.get_product_by_slug(params["product"])
product = product || Products.get_product(review.product_id)
changeset = Review.update_changeset(review, %{})
socket
|> assign(:review, review)
|> assign(:product, product)
|> assign(:form, to_form(changeset))
|> assign(:page_title, "Edit your review")
else
assign(socket, :error, "You don't have permission to edit this review.")
end
end
end
@impl true
def handle_event("validate", %{"review" => params}, socket) do
changeset =
if socket.assigns.review do
Review.update_changeset(socket.assigns.review, params)
else
Review.changeset(%Review{}, Map.put(params, "product_id", socket.assigns.product.id))
end
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"review" => params}, socket) do
if socket.assigns.review do
update_review(socket, params)
else
create_review(socket, params)
end
end
def handle_event("set_rating", %{"rating" => rating}, socket) do
form = socket.assigns.form
params = Map.put(form.params || %{}, "rating", rating)
changeset =
if socket.assigns.review do
Review.update_changeset(socket.assigns.review, params)
else
Review.changeset(%Review{}, Map.put(params, "product_id", socket.assigns.product.id))
end
{:noreply, assign(socket, :form, to_form(changeset))}
end
defp create_review(socket, params) do
params =
params
|> Map.put("product_id", socket.assigns.product.id)
|> Map.put("email", socket.assigns.email)
case Reviews.create_review(params) do
{:ok, _review} ->
{:noreply,
socket
|> assign(:submitted, true)
|> put_flash(:info, "Thanks! Your review will appear once approved.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp update_review(socket, params) do
case Reviews.update_review(socket.assigns.review, params) do
{:ok, _review} ->
{:noreply,
socket
|> assign(:submitted, true)
|> put_flash(:info, "Your updated review will appear once approved.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
@impl true
def render(assigns) do
~H"""
<div class="review-form-page">
<%= cond do %>
<% @submitted -> %>
<.success_message product={@product} />
<% @error -> %>
<.error_message error={@error} product={@product} review={@review} />
<% @form -> %>
<.review_form form={@form} product={@product} review={@review} />
<% true -> %>
<p>Loading...</p>
<% end %>
</div>
"""
end
defp success_message(assigns) do
~H"""
<div class="review-success">
<svg
class="review-success-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<h1>Thanks for your review!</h1>
<p>Your review will appear on the product page once it's been approved.</p>
<.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
Back to {@product.title}
</.link>
</div>
"""
end
defp error_message(assigns) do
~H"""
<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 %>
</div>
"""
end
defp review_form(assigns) do
~H"""
<div class="review-form-container">
<h1 class="review-form-heading">
{if @review, do: "Edit your review", else: "Write a review"}
</h1>
<p class="review-form-product">{@product.title}</p>
<.form for={@form} phx-change="validate" phx-submit="save" class="review-form">
<div class="review-form-field">
<label class="review-form-label">Rating</label>
<.star_rating_input rating={@form[:rating].value} />
<p
:for={msg <- Enum.map(@form[:rating].errors, &translate_error/1)}
class="review-form-error"
>
{msg}
</p>
</div>
<div class="review-form-field">
<label for="review_author_name" class="review-form-label">Your name</label>
<input
type="text"
id="review_author_name"
name="review[author_name]"
value={@form[:author_name].value}
required
maxlength="50"
class="review-form-input"
placeholder="How should we display your name?"
/>
<p
:for={msg <- Enum.map(@form[:author_name].errors, &translate_error/1)}
class="review-form-error"
>
{msg}
</p>
</div>
<div class="review-form-field">
<label for="review_title" class="review-form-label">
Title <span class="review-form-optional">(optional)</span>
</label>
<input
type="text"
id="review_title"
name="review[title]"
value={@form[:title].value}
maxlength="100"
class="review-form-input"
placeholder="Summarise your experience"
/>
<p
:for={msg <- Enum.map(@form[:title].errors, &translate_error/1)}
class="review-form-error"
>
{msg}
</p>
</div>
<div class="review-form-field">
<label for="review_body" class="review-form-label">
Review <span class="review-form-optional">(optional)</span>
</label>
<textarea
id="review_body"
name="review[body]"
maxlength="2000"
rows="5"
class="review-form-textarea"
placeholder="Share the details of your experience"
>{@form[:body].value}</textarea>
<p :for={msg <- Enum.map(@form[:body].errors, &translate_error/1)} class="review-form-error">
{msg}
</p>
</div>
<button type="submit" class="review-form-submit">
{if @review, do: "Update review", else: "Submit review"}
</button>
</.form>
</div>
"""
end
defp star_rating_input(assigns) do
rating = assigns.rating
rating = if is_binary(rating), do: String.to_integer(rating), else: rating
assigns = assign(assigns, :rating, rating)
~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" />
</svg>
</button>
<% end %>
</div>
"""
end
end