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

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

View File

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

View File

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

View 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

View File

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

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

View File

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

View File

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

View File

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

View File

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