From 32eb0c6758a92d9ba78b51e6b0c781228d55aeff Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 1 Apr 2026 11:12:25 +0100 Subject: [PATCH] add review submission flow (phase 3) - 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 --- docs/plans/reviews-system.md | 12 +- lib/berrypod/pages/block_types.ex | 54 ++- lib/berrypod/reviews.ex | 70 ++++ lib/berrypod/reviews/review_notifier.ex | 64 ++++ .../components/shop_components/content.ex | 135 ++++++- .../live/shop/pages/order_detail.ex | 13 +- lib/berrypod_web/live/shop/pages/product.ex | 56 ++- lib/berrypod_web/live/shop/review_form.ex | 357 ++++++++++++++++++ lib/berrypod_web/page_renderer.ex | 28 +- lib/berrypod_web/router.ex | 5 + test/berrypod/pages_test.exs | 6 +- test/berrypod/reviews_test.exs | 69 ++++ 12 files changed, 835 insertions(+), 34 deletions(-) create mode 100644 lib/berrypod/reviews/review_notifier.ex create mode 100644 lib/berrypod_web/live/shop/review_form.ex 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 %>
-
- <%= for review <- @reviews do %> - <.review_card review={review} /> - <% end %> -
+ <.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 - + <.existing_review_notice :if={@existing_review} review={@existing_review} product={@product} /> + + <%= if @has_reviews do %> +
+ <%= for review <- @reviews do %> + <.review_card review={review} /> + <% end %> +
+ + <.shop_button_outline :if={length(@reviews) < @display_count} class="reviews-load-more"> + Load more reviews + + <% else %> +

No reviews yet. Be the first to share your experience!

+ <% 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""" +
+

Write a review

+ + <%= if @review_status do %> + <.review_status_message status={@review_status} /> + <% end %> + +
+

+ Enter the email you used to purchase this product. We'll send you a link to leave your review. +

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

+ {if @review, do: "Edit your review", else: "Write a review"} +

+

{@product.title}

+ + <.form for={@form} phx-change="validate" phx-submit="save" class="review-form"> +
+ + <.star_rating_input rating={@form[:rating].value} /> +

+ {msg} +

+
+ +
+ + +

+ {msg} +

+
+ +
+ + +

+ {msg} +

+
+ +
+ + +

+ {msg} +

+
+ + + +
+ """ + 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""" +
+ <%= for i <- 1..5 do %> + + <% end %> +
+ """ + 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 %> {item.product_name} <% 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