add review submission flow (phase 3)
All checks were successful
deploy / deploy (push) Successful in 1m10s
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:
parent
8dc17a6f4d
commit
32eb0c6758
@ -1,6 +1,6 @@
|
||||
# Product reviews system
|
||||
|
||||
Status: In Progress (Phase 1-2 complete)
|
||||
Status: In Progress (Phase 1-3 complete)
|
||||
|
||||
## Overview
|
||||
|
||||
@ -428,22 +428,22 @@ Only include if product has approved reviews.
|
||||
- Purchase verification
|
||||
- Image preloading helpers
|
||||
|
||||
### Phase 3: Review submission (~4h)
|
||||
### Phase 3: Review submission (~4h) ✓
|
||||
|
||||
5. **Product page review section** (1.5h)
|
||||
5. **Product page review section** (1.5h) ✓
|
||||
- Email entry form (if no session)
|
||||
- Review form (if session + can review)
|
||||
- Edit existing review
|
||||
- Verification email sending
|
||||
|
||||
6. **Review form LiveView** (2h)
|
||||
6. **Review form LiveView** (2h) ✓
|
||||
- Token verification
|
||||
- Form with star rating
|
||||
- Photo upload with LiveView uploads (max 3)
|
||||
- Photo upload with LiveView uploads (max 3) - deferred to Phase 4
|
||||
- Create/update handling
|
||||
- Photo processing via existing pipeline
|
||||
|
||||
7. **Orders page integration** (0.5h)
|
||||
7. **Orders page integration** (0.5h) ✓
|
||||
- "Write a review" / "Edit review" buttons
|
||||
- Link to review form
|
||||
|
||||
|
||||
@ -454,11 +454,57 @@ defmodule Berrypod.Pages.BlockTypes do
|
||||
%{related_products: products}
|
||||
end
|
||||
|
||||
defp run_loader(:load_reviews, _assigns, _settings) do
|
||||
defp run_loader(:load_reviews, assigns, _settings) do
|
||||
if assigns[:mode] == :preview do
|
||||
%{
|
||||
reviews: PreviewData.reviews(),
|
||||
average_rating: Decimal.new(5),
|
||||
review_count: 2
|
||||
}
|
||||
else
|
||||
case assigns[:product] do
|
||||
%{id: product_id} ->
|
||||
reviews =
|
||||
Berrypod.Reviews.list_reviews_for_product(product_id, limit: 10)
|
||||
|> Enum.map(&format_review_for_display/1)
|
||||
|
||||
{avg, count} = Berrypod.Reviews.average_rating_for_product(product_id)
|
||||
|
||||
%{
|
||||
reviews: reviews,
|
||||
average_rating: avg,
|
||||
review_count: count
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{reviews: [], average_rating: nil, review_count: 0}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp format_review_for_display(review) do
|
||||
%{
|
||||
reviews: PreviewData.reviews(),
|
||||
average_rating: 5,
|
||||
total_count: 24
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@ -223,6 +223,76 @@ defmodule Berrypod.Reviews do
|
||||
|
||||
def get_review_images(_), do: []
|
||||
|
||||
# ── Review tokens ──────────────────────────────────────────────────
|
||||
|
||||
@review_salt "review_verification_v1"
|
||||
# 1 hour
|
||||
@review_token_max_age 3600
|
||||
|
||||
@doc """
|
||||
Generates a signed token for review verification.
|
||||
|
||||
Token encodes email and product_id, valid for 1 hour.
|
||||
"""
|
||||
def generate_review_token(email, product_id) do
|
||||
Phoenix.Token.sign(
|
||||
BerrypodWeb.Endpoint,
|
||||
@review_salt,
|
||||
%{email: normalise_email(email), product_id: product_id}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies a review token and returns the email and product_id.
|
||||
|
||||
Returns `{:ok, %{email: email, product_id: product_id}}` or `{:error, :invalid}`.
|
||||
"""
|
||||
def verify_review_token(token) do
|
||||
case Phoenix.Token.verify(BerrypodWeb.Endpoint, @review_salt, token,
|
||||
max_age: @review_token_max_age
|
||||
) do
|
||||
{:ok, %{email: email, product_id: product_id}} ->
|
||||
{:ok, %{email: email, product_id: product_id}}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a review verification email if the email has purchased the product.
|
||||
|
||||
Returns:
|
||||
- `{:ok, :sent}` if email was sent
|
||||
- `{:error, :no_purchase}` if email hasn't purchased the product
|
||||
- `{:error, :already_reviewed}` if they've already reviewed
|
||||
- `{:error, reason}` if email sending failed
|
||||
"""
|
||||
def request_review_verification(email, product_id, product_title) do
|
||||
email = normalise_email(email)
|
||||
|
||||
cond do
|
||||
get_review_by_email_and_product(email, product_id) ->
|
||||
{:error, :already_reviewed}
|
||||
|
||||
not can_review?(email, product_id) ->
|
||||
{:error, :no_purchase}
|
||||
|
||||
true ->
|
||||
token = generate_review_token(email, product_id)
|
||||
link = BerrypodWeb.Endpoint.url() <> "/reviews/new?token=#{token}"
|
||||
|
||||
case Berrypod.Reviews.ReviewNotifier.deliver_review_verification(
|
||||
email,
|
||||
product_title,
|
||||
link
|
||||
) do
|
||||
{:ok, _} -> {:ok, :sent}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp normalise_email(email) when is_binary(email) do
|
||||
|
||||
64
lib/berrypod/reviews/review_notifier.ex
Normal file
64
lib/berrypod/reviews/review_notifier.ex
Normal file
@ -0,0 +1,64 @@
|
||||
defmodule Berrypod.Reviews.ReviewNotifier do
|
||||
@moduledoc """
|
||||
Sends transactional emails for the review system.
|
||||
|
||||
Verification emails for review submission, and review request emails
|
||||
after order delivery.
|
||||
"""
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Berrypod.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a verification email with a link to leave a review.
|
||||
|
||||
The link contains a signed token encoding the email and product_id.
|
||||
"""
|
||||
def deliver_review_verification(email, product_title, link) do
|
||||
subject = "Leave a review for #{product_title}"
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Thanks for your purchase!
|
||||
|
||||
Click here to leave a review for #{product_title}:
|
||||
|
||||
#{link}
|
||||
|
||||
This link expires in 1 hour.
|
||||
|
||||
If you didn't request this, you can ignore this email.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(email, subject, body)
|
||||
end
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({shop_name, from_address})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Failed to send review email to #{recipient}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -963,24 +963,37 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
- `author` - Reviewer name
|
||||
- `date` - Relative date string (e.g., "2 weeks ago")
|
||||
- `verified` - Boolean, if true shows "Verified purchase" badge
|
||||
* `average_rating` - Optional. Average rating to show in header. Defaults to 5.
|
||||
* `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.
|
||||
* `product` - Optional. The product struct for review submission.
|
||||
* `email_session` - Optional. Email from session cookie.
|
||||
* `existing_review` - Optional. User's existing review if any.
|
||||
* `review_status` - Optional. Status message tuple {type, message}.
|
||||
|
||||
## Examples
|
||||
|
||||
<.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} />
|
||||
"""
|
||||
attr :reviews, :list, required: true
|
||||
attr :average_rating, :integer, default: 5
|
||||
attr :average_rating, :any, default: nil
|
||||
attr :total_count, :integer, default: nil
|
||||
attr :open, :boolean, default: true
|
||||
attr :product, :any, default: nil
|
||||
attr :email_session, :string, default: nil
|
||||
attr :existing_review, :any, default: nil
|
||||
attr :review_form, :any, default: nil
|
||||
attr :review_status, :any, default: nil
|
||||
|
||||
def reviews_section(assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :display_count, fn ->
|
||||
assigns
|
||||
|> assign_new(:display_count, fn ->
|
||||
assigns.total_count || length(assigns.reviews)
|
||||
end)
|
||||
|> assign_new(:has_reviews, fn ->
|
||||
assigns.reviews != [] and assigns.total_count != 0
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<details open={@open} class="pdp-reviews">
|
||||
@ -989,10 +1002,12 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<h2 class="reviews-heading">
|
||||
Customer reviews
|
||||
</h2>
|
||||
<div class="reviews-rating-group">
|
||||
<.star_rating rating={@average_rating} />
|
||||
<span class="reviews-count">({@display_count})</span>
|
||||
</div>
|
||||
<%= if @has_reviews do %>
|
||||
<div class="reviews-rating-group">
|
||||
<.star_rating rating={@average_rating} />
|
||||
<span class="reviews-count">({@display_count})</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<svg
|
||||
class="reviews-chevron"
|
||||
@ -1007,20 +1022,110 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
</summary>
|
||||
|
||||
<div class="reviews-body">
|
||||
<div class="reviews-list">
|
||||
<%= for review <- @reviews do %>
|
||||
<.review_card review={review} />
|
||||
<% end %>
|
||||
</div>
|
||||
<.review_request_form
|
||||
:if={@product && !@existing_review}
|
||||
product={@product}
|
||||
email_session={@email_session}
|
||||
review_status={@review_status}
|
||||
/>
|
||||
|
||||
<.shop_button_outline class="reviews-load-more">
|
||||
Load more reviews
|
||||
</.shop_button_outline>
|
||||
<.existing_review_notice :if={@existing_review} review={@existing_review} product={@product} />
|
||||
|
||||
<%= if @has_reviews do %>
|
||||
<div class="reviews-list">
|
||||
<%= for review <- @reviews do %>
|
||||
<.review_card review={review} />
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.shop_button_outline :if={length(@reviews) < @display_count} class="reviews-load-more">
|
||||
Load more reviews
|
||||
</.shop_button_outline>
|
||||
<% else %>
|
||||
<p class="reviews-empty">No reviews yet. Be the first to share your experience!</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the review request form for verified purchasers.
|
||||
"""
|
||||
attr :product, :any, required: true
|
||||
attr :email_session, :string, default: nil
|
||||
attr :review_status, :any, default: nil
|
||||
|
||||
def review_request_form(assigns) do
|
||||
~H"""
|
||||
<div class="review-request-form">
|
||||
<h3 class="review-request-title">Write a review</h3>
|
||||
|
||||
<%= if @review_status do %>
|
||||
<.review_status_message status={@review_status} />
|
||||
<% end %>
|
||||
|
||||
<form phx-submit="request_review" class="review-email-form">
|
||||
<p class="review-request-hint">
|
||||
Enter the email you used to purchase this product. We'll send you a link to leave your review.
|
||||
</p>
|
||||
<div class="review-email-row">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={@email_session}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
class="review-email-input"
|
||||
/>
|
||||
<button type="submit" class="review-email-submit">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows a notice that the user has already reviewed this product.
|
||||
"""
|
||||
attr :review, :any, required: true
|
||||
attr :product, :any, required: true
|
||||
|
||||
def existing_review_notice(assigns) do
|
||||
~H"""
|
||||
<div class="existing-review-notice">
|
||||
<p>
|
||||
You've already reviewed this product.
|
||||
<.link
|
||||
href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
|
||||
class="existing-review-edit-link"
|
||||
>
|
||||
Edit your review
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a status message for review operations.
|
||||
"""
|
||||
attr :status, :any, required: true
|
||||
|
||||
def review_status_message(assigns) do
|
||||
{type, message} = assigns.status
|
||||
assigns = assign(assigns, :type, type)
|
||||
assigns = assign(assigns, :message, message)
|
||||
|
||||
~H"""
|
||||
<div class={"review-status review-status-#{@type}"}>
|
||||
{@message}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single review card.
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
357
lib/berrypod_web/live/shop/review_form.ex
Normal file
357
lib/berrypod_web/live/shop/review_form.ex
Normal 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
|
||||
@ -812,8 +812,13 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
<.reviews_section
|
||||
:if={@theme_settings.pdp_reviews}
|
||||
reviews={assigns[:reviews] || []}
|
||||
average_rating={assigns[:average_rating] || 5}
|
||||
total_count={assigns[:total_count]}
|
||||
average_rating={assigns[:average_rating]}
|
||||
total_count={assigns[:review_count]}
|
||||
product={assigns[:product]}
|
||||
email_session={assigns[:email_session]}
|
||||
existing_review={assigns[:existing_review]}
|
||||
review_form={assigns[:review_form]}
|
||||
review_status={assigns[:review_status]}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
@ -1388,7 +1393,7 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
<%= if info && info.thumb do %>
|
||||
<img src={info.thumb} alt={item.product_name} class="checkout-item-thumb" />
|
||||
<% end %>
|
||||
<div>
|
||||
<div class="checkout-item-details">
|
||||
<%= if info && info.slug do %>
|
||||
<.link
|
||||
patch={R.product(info.slug)}
|
||||
@ -1403,6 +1408,23 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
<p class="checkout-item-detail">{item.variant_title}</p>
|
||||
<% end %>
|
||||
<p class="checkout-item-detail">Qty: {item.quantity}</p>
|
||||
<%= if info && info.slug do %>
|
||||
<%= if info[:existing_review] do %>
|
||||
<.link
|
||||
href={"/reviews/#{info.existing_review.id}/edit?product=#{info.slug}"}
|
||||
class="checkout-item-review-link"
|
||||
>
|
||||
Edit your review
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
patch={R.product(info.slug) <> "#reviews"}
|
||||
class="checkout-item-review-link"
|
||||
>
|
||||
Write a review
|
||||
</.link>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="checkout-item-price">
|
||||
{Cart.format_price(item.unit_price * item.quantity)}
|
||||
|
||||
@ -296,6 +296,11 @@ defmodule BerrypodWeb.Router do
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
live "/", Shop.Page, :home
|
||||
|
||||
# Review routes (before catch-all)
|
||||
live "/reviews/new", Shop.ReviewForm, :new
|
||||
live "/reviews/:id/edit", Shop.ReviewForm, :edit
|
||||
|
||||
live "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefix
|
||||
live "/:slug", Shop.Page, :custom_page
|
||||
end
|
||||
|
||||
@ -311,11 +311,11 @@ defmodule Berrypod.PagesTest do
|
||||
|
||||
test "loads review data when reviews_section block is present" do
|
||||
blocks = [%{"type" => "reviews_section", "settings" => %{}}]
|
||||
data = Pages.load_block_data(blocks, %{})
|
||||
data = Pages.load_block_data(blocks, %{mode: :preview})
|
||||
|
||||
assert is_list(data.reviews)
|
||||
assert data.average_rating == 5
|
||||
assert data.total_count == 24
|
||||
assert Decimal.equal?(data.average_rating, Decimal.new(5))
|
||||
assert data.review_count == 2
|
||||
end
|
||||
|
||||
test "loads featured products with configurable count" do
|
||||
|
||||
@ -382,4 +382,73 @@ defmodule Berrypod.ReviewsTest do
|
||||
assert Reviews.get_review(review.id) == nil
|
||||
end
|
||||
end
|
||||
|
||||
# ── Review tokens ────────────────────────────────────────────────────
|
||||
|
||||
describe "generate_review_token/2 and verify_review_token/1" do
|
||||
test "generates and verifies a valid token" do
|
||||
product = product_fixture()
|
||||
email = "buyer@example.com"
|
||||
|
||||
token = Reviews.generate_review_token(email, product.id)
|
||||
assert is_binary(token)
|
||||
|
||||
{:ok, result} = Reviews.verify_review_token(token)
|
||||
assert result.email == email
|
||||
assert result.product_id == product.id
|
||||
end
|
||||
|
||||
test "normalises email to lowercase" do
|
||||
product = product_fixture()
|
||||
token = Reviews.generate_review_token("BUYER@EXAMPLE.COM", product.id)
|
||||
|
||||
{:ok, result} = Reviews.verify_review_token(token)
|
||||
assert result.email == "buyer@example.com"
|
||||
end
|
||||
|
||||
test "rejects invalid tokens" do
|
||||
assert {:error, :invalid} = Reviews.verify_review_token("invalid_token")
|
||||
end
|
||||
end
|
||||
|
||||
describe "request_review_verification/3" do
|
||||
test "returns error when email has not purchased product" do
|
||||
product = product_fixture()
|
||||
|
||||
result =
|
||||
Reviews.request_review_verification("nobody@example.com", product.id, product.title)
|
||||
|
||||
assert {:error, :no_purchase} = result
|
||||
end
|
||||
|
||||
test "returns error when user has already reviewed" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, _} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
result = Reviews.request_review_verification("buyer@example.com", product.id, product.title)
|
||||
assert {:error, :already_reviewed} = result
|
||||
end
|
||||
|
||||
test "sends verification email when user has purchased" do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
product = product_fixture(%{provider_connection: conn})
|
||||
variant = product_variant_fixture(%{product: product})
|
||||
|
||||
order_fixture(%{
|
||||
customer_email: "buyer@example.com",
|
||||
payment_status: "paid",
|
||||
variant_id: variant.id
|
||||
})
|
||||
|
||||
result = Reviews.request_review_verification("buyer@example.com", product.id, product.title)
|
||||
assert {:ok, :sent} = result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Loading…
Reference in New Issue
Block a user