diff --git a/docs/plans/reviews-system.md b/docs/plans/reviews-system.md
index 95cb680..ac18221 100644
--- a/docs/plans/reviews-system.md
+++ b/docs/plans/reviews-system.md
@@ -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
diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex
index 41826b0..3b6b20d 100644
--- a/lib/berrypod/pages/block_types.ex
+++ b/lib/berrypod/pages/block_types.ex
@@ -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
diff --git a/lib/berrypod/reviews.ex b/lib/berrypod/reviews.ex
index 7ccb8e4..7686b31 100644
--- a/lib/berrypod/reviews.ex
+++ b/lib/berrypod/reviews.ex
@@ -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
diff --git a/lib/berrypod/reviews/review_notifier.ex b/lib/berrypod/reviews/review_notifier.ex
new file mode 100644
index 0000000..629e50f
--- /dev/null
+++ b/lib/berrypod/reviews/review_notifier.ex
@@ -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
diff --git a/lib/berrypod_web/components/shop_components/content.ex b/lib/berrypod_web/components/shop_components/content.ex
index b1f7a82..2e9e3a3 100644
--- a/lib/berrypod_web/components/shop_components/content.ex
+++ b/lib/berrypod_web/components/shop_components/content.ex
@@ -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"""
@@ -989,10 +1002,12 @@ defmodule BerrypodWeb.ShopComponents.Content do
Customer reviews
-
- <.star_rating rating={@average_rating} />
- ({@display_count})
-
+ <%= if @has_reviews do %>
+
+ <.star_rating rating={@average_rating} />
+ ({@display_count})
+
+ <% end %>
"""
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"""
+
+ """
+ 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"""
+
+
+ You've already reviewed this product.
+ <.link
+ href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
+ class="existing-review-edit-link"
+ >
+ Edit your review
+
+
+
+ """
+ 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"""
+
+ {@message}
+
+ """
+ end
+
@doc """
Renders a single review card.
diff --git a/lib/berrypod_web/live/shop/pages/order_detail.ex b/lib/berrypod_web/live/shop/pages/order_detail.ex
index de22851..7b13308 100644
--- a/lib/berrypod_web/live/shop/pages/order_detail.ex
+++ b/lib/berrypod_web/live/shop/pages/order_detail.ex
@@ -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 =
diff --git a/lib/berrypod_web/live/shop/pages/product.ex b/lib/berrypod_web/live/shop/pages/product.ex
index f942b0c..3ef8fb7 100644
--- a/lib/berrypod_web/live/shop/pages/product.ex
+++ b/lib/berrypod_web/live/shop/pages/product.ex
@@ -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
diff --git a/lib/berrypod_web/live/shop/review_form.ex b/lib/berrypod_web/live/shop/review_form.ex
new file mode 100644
index 0000000..e8417e2
--- /dev/null
+++ b/lib/berrypod_web/live/shop/review_form.ex
@@ -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"""
+
+ <%= 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 -> %>
+
Loading...
+ <% end %>
+
+ """
+ end
+
+ defp success_message(assigns) do
+ ~H"""
+
+
+
Thanks for your review!
+
Your review will appear on the product page once it's been approved.
+ <.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
+ Back to {@product.title}
+
+
+ """
+ end
+
+ defp error_message(assigns) do
+ ~H"""
+
+
Unable to leave review
+
{@error}
+ <%= if @product do %>
+ <.link href={BerrypodWeb.R.product(@product.slug)} class="review-back-link">
+ Back to {@product.title}
+
+ <%= if @review do %>
+ <.link
+ href={"/reviews/#{@review.id}/edit?product=#{@product.slug}"}
+ class="review-edit-link"
+ >
+ Edit your existing review
+
+ <% end %>
+ <% else %>
+ <.link href={BerrypodWeb.R.home()} class="review-back-link">
+ Back to shop
+
+ <% end %>
+
+ """
+ end
+
+ defp review_form(assigns) do
+ ~H"""
+
+ """
+ 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"""
+
+ """
+ end
+end
diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex
index ee37b06..7fc09cc 100644
--- a/lib/berrypod_web/page_renderer.ex
+++ b/lib/berrypod_web/page_renderer.ex
@@ -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 %>
<% end %>
-
+
<%= if info && info.slug do %>
<.link
patch={R.product(info.slug)}
@@ -1403,6 +1408,23 @@ defmodule BerrypodWeb.PageRenderer do
{item.variant_title}
<% end %>
Qty: {item.quantity}
+ <%= 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
+
+ <% else %>
+ <.link
+ patch={R.product(info.slug) <> "#reviews"}
+ class="checkout-item-review-link"
+ >
+ Write a review
+
+ <% end %>
+ <% end %>
{Cart.format_price(item.unit_price * item.quantity)}
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index 7d916a0..08b4ceb 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -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
diff --git a/test/berrypod/pages_test.exs b/test/berrypod/pages_test.exs
index eaef418..ebe9393 100644
--- a/test/berrypod/pages_test.exs
+++ b/test/berrypod/pages_test.exs
@@ -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
diff --git a/test/berrypod/reviews_test.exs b/test/berrypod/reviews_test.exs
index b4a3188..ad54995 100644
--- a/test/berrypod/reviews_test.exs
+++ b/test/berrypod/reviews_test.exs
@@ -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