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
|
# Product reviews system
|
||||||
|
|
||||||
Status: In Progress (Phase 1-2 complete)
|
Status: In Progress (Phase 1-3 complete)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@ -428,22 +428,22 @@ Only include if product has approved reviews.
|
|||||||
- Purchase verification
|
- Purchase verification
|
||||||
- Image preloading helpers
|
- 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)
|
- Email entry form (if no session)
|
||||||
- Review form (if session + can review)
|
- Review form (if session + can review)
|
||||||
- Edit existing review
|
- Edit existing review
|
||||||
- Verification email sending
|
- Verification email sending
|
||||||
|
|
||||||
6. **Review form LiveView** (2h)
|
6. **Review form LiveView** (2h) ✓
|
||||||
- Token verification
|
- Token verification
|
||||||
- Form with star rating
|
- 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
|
- Create/update handling
|
||||||
- Photo processing via existing pipeline
|
- Photo processing via existing pipeline
|
||||||
|
|
||||||
7. **Orders page integration** (0.5h)
|
7. **Orders page integration** (0.5h) ✓
|
||||||
- "Write a review" / "Edit review" buttons
|
- "Write a review" / "Edit review" buttons
|
||||||
- Link to review form
|
- Link to review form
|
||||||
|
|
||||||
|
|||||||
@ -454,11 +454,57 @@ defmodule Berrypod.Pages.BlockTypes do
|
|||||||
%{related_products: products}
|
%{related_products: products}
|
||||||
end
|
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(),
|
id: review.id,
|
||||||
average_rating: 5,
|
rating: review.rating,
|
||||||
total_count: 24
|
title: review.title,
|
||||||
|
body: review.body,
|
||||||
|
author: review.author_name,
|
||||||
|
date: relative_time(review.inserted_at),
|
||||||
|
verified: not is_nil(review.order_id)
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -223,6 +223,76 @@ defmodule Berrypod.Reviews do
|
|||||||
|
|
||||||
def get_review_images(_), 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 ────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp normalise_email(email) when is_binary(email) do
|
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
|
- `author` - Reviewer name
|
||||||
- `date` - Relative date string (e.g., "2 weeks ago")
|
- `date` - Relative date string (e.g., "2 weeks ago")
|
||||||
- `verified` - Boolean, if true shows "Verified purchase" badge
|
- `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.
|
* `total_count` - Optional. Total number of reviews. Defaults to length of reviews list.
|
||||||
* `open` - Optional. Whether section is expanded by default. Defaults to true.
|
* `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
|
## Examples
|
||||||
|
|
||||||
<.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} />
|
<.reviews_section reviews={@product.reviews} average_rating={4.8} total_count={24} />
|
||||||
"""
|
"""
|
||||||
attr :reviews, :list, required: true
|
attr :reviews, :list, required: true
|
||||||
attr :average_rating, :integer, default: 5
|
attr :average_rating, :any, default: nil
|
||||||
attr :total_count, :integer, default: nil
|
attr :total_count, :integer, default: nil
|
||||||
attr :open, :boolean, default: true
|
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
|
def reviews_section(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
assign_new(assigns, :display_count, fn ->
|
assigns
|
||||||
|
|> assign_new(:display_count, fn ->
|
||||||
assigns.total_count || length(assigns.reviews)
|
assigns.total_count || length(assigns.reviews)
|
||||||
end)
|
end)
|
||||||
|
|> assign_new(:has_reviews, fn ->
|
||||||
|
assigns.reviews != [] and assigns.total_count != 0
|
||||||
|
end)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<details open={@open} class="pdp-reviews">
|
<details open={@open} class="pdp-reviews">
|
||||||
@ -989,10 +1002,12 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
<h2 class="reviews-heading">
|
<h2 class="reviews-heading">
|
||||||
Customer reviews
|
Customer reviews
|
||||||
</h2>
|
</h2>
|
||||||
<div class="reviews-rating-group">
|
<%= if @has_reviews do %>
|
||||||
<.star_rating rating={@average_rating} />
|
<div class="reviews-rating-group">
|
||||||
<span class="reviews-count">({@display_count})</span>
|
<.star_rating rating={@average_rating} />
|
||||||
</div>
|
<span class="reviews-count">({@display_count})</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
class="reviews-chevron"
|
class="reviews-chevron"
|
||||||
@ -1007,20 +1022,110 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="reviews-body">
|
<div class="reviews-body">
|
||||||
<div class="reviews-list">
|
<.review_request_form
|
||||||
<%= for review <- @reviews do %>
|
:if={@product && !@existing_review}
|
||||||
<.review_card review={review} />
|
product={@product}
|
||||||
<% end %>
|
email_session={@email_session}
|
||||||
</div>
|
review_status={@review_status}
|
||||||
|
/>
|
||||||
|
|
||||||
<.shop_button_outline class="reviews-load-more">
|
<.existing_review_notice :if={@existing_review} review={@existing_review} product={@product} />
|
||||||
Load more reviews
|
|
||||||
</.shop_button_outline>
|
<%= 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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
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"""
|
||||||
|
<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 """
|
@doc """
|
||||||
Renders a single review card.
|
Renders a single review card.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
|
|||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
import Phoenix.LiveView, only: [push_navigate: 2]
|
import Phoenix.LiveView, only: [push_navigate: 2]
|
||||||
|
|
||||||
alias Berrypod.{Orders, Pages}
|
alias Berrypod.{Orders, Pages, Reviews}
|
||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
alias Berrypod.Products.ProductImage
|
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
|
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)
|
end)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|||||||
@ -6,7 +6,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
import Phoenix.Component, only: [assign: 2, assign: 3]
|
import Phoenix.Component, only: [assign: 2, assign: 3]
|
||||||
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
|
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
|
||||||
|
|
||||||
alias Berrypod.{Analytics, Cart, Pages}
|
alias Berrypod.{Analytics, Cart, Pages, Reviews}
|
||||||
alias BerrypodWeb.R
|
alias BerrypodWeb.R
|
||||||
alias Berrypod.Images.Optimizer
|
alias Berrypod.Images.Optimizer
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
@ -69,6 +69,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
|> assign(:variants, variants)
|
|> assign(:variants, variants)
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|> assign(:product_discontinued, is_discontinued)
|
|> 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
|
# Block data loaders (related_products, reviews) run after product is assigned
|
||||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||||
@ -126,8 +132,56 @@ defmodule BerrypodWeb.Shop.Pages.Product do
|
|||||||
end
|
end
|
||||||
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
|
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 ──────────────────────────────────────────
|
# ── Variant selection logic ──────────────────────────────────────────
|
||||||
|
|
||||||
defp apply_variant_params(params, socket) do
|
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
|
<.reviews_section
|
||||||
:if={@theme_settings.pdp_reviews}
|
:if={@theme_settings.pdp_reviews}
|
||||||
reviews={assigns[:reviews] || []}
|
reviews={assigns[:reviews] || []}
|
||||||
average_rating={assigns[:average_rating] || 5}
|
average_rating={assigns[:average_rating]}
|
||||||
total_count={assigns[:total_count]}
|
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
|
end
|
||||||
@ -1388,7 +1393,7 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<%= if info && info.thumb do %>
|
<%= if info && info.thumb do %>
|
||||||
<img src={info.thumb} alt={item.product_name} class="checkout-item-thumb" />
|
<img src={info.thumb} alt={item.product_name} class="checkout-item-thumb" />
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div class="checkout-item-details">
|
||||||
<%= if info && info.slug do %>
|
<%= if info && info.slug do %>
|
||||||
<.link
|
<.link
|
||||||
patch={R.product(info.slug)}
|
patch={R.product(info.slug)}
|
||||||
@ -1403,6 +1408,23 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<p class="checkout-item-detail">{item.variant_title}</p>
|
<p class="checkout-item-detail">{item.variant_title}</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p class="checkout-item-detail">Qty: {item.quantity}</p>
|
<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>
|
</div>
|
||||||
<span class="checkout-item-price">
|
<span class="checkout-item-price">
|
||||||
{Cart.format_price(item.unit_price * item.quantity)}
|
{Cart.format_price(item.unit_price * item.quantity)}
|
||||||
|
|||||||
@ -296,6 +296,11 @@ defmodule BerrypodWeb.Router do
|
|||||||
# └─────────────────────────────────────────────────────────────────────┘
|
# └─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
live "/", Shop.Page, :home
|
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 "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefix
|
||||||
live "/:slug", Shop.Page, :custom_page
|
live "/:slug", Shop.Page, :custom_page
|
||||||
end
|
end
|
||||||
|
|||||||
@ -311,11 +311,11 @@ defmodule Berrypod.PagesTest do
|
|||||||
|
|
||||||
test "loads review data when reviews_section block is present" do
|
test "loads review data when reviews_section block is present" do
|
||||||
blocks = [%{"type" => "reviews_section", "settings" => %{}}]
|
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 is_list(data.reviews)
|
||||||
assert data.average_rating == 5
|
assert Decimal.equal?(data.average_rating, Decimal.new(5))
|
||||||
assert data.total_count == 24
|
assert data.review_count == 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "loads featured products with configurable count" do
|
test "loads featured products with configurable count" do
|
||||||
|
|||||||
@ -382,4 +382,73 @@ defmodule Berrypod.ReviewsTest do
|
|||||||
assert Reviews.get_review(review.id) == nil
|
assert Reviews.get_review(review.id) == nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user