- Create reviews table with product/order associations - Add Review schema with create/update/moderation changesets - Add Reviews context with CRUD, purchase verification, aggregates - Add get_images/1 to Media, get_variant/1 to Products - 23 tests covering all context functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
Product reviews system
Status: In Progress (Phase 1-2 complete)
Overview
A review system for verified purchasers without requiring customer accounts. Uses email verification with a persistent browser session token (similar to "remember me") so customers can leave reviews, view orders, and edit reviews without re-verifying each time.
Customers can upload photos with their reviews (1-3 images). Photos display inline as thumbnails and open in the existing lightbox component.
Design principles
- No customer accounts — email verification only, stored as a browser session
- One review per email+product — prevents spam, simplifies UX
- Verified purchases only — must have a paid order containing the product
- Edit forever — via new magic link if session expired, resets to pending for re-moderation
- Moderation by default — reviews start as pending, admin approves
- Photos optional — 1-3 images per review, no video (yet)
Email session token
Replaces the old short-lived session-based order lookup with a persistent cookie.
Flow (30 days, multi-purpose)
- User enters email on
/contact→ system sends verification link with 1-hour token - On verify, sets a signed cookie:
email_sessioncontaining email - Cookie lasts 30 days, httponly, secure, same-site lax
- Also set on checkout completion (via
/checkout/completecontroller) - Cookie checked by: order lookup, order detail, review submission, review editing
Implementation
New module: lib/berrypod/email_session.ex
defmodule Berrypod.EmailSession do
@cookie_name "email_session"
@max_age 30 * 24 * 60 * 60 # 30 days
@salt "email_session_v1"
def put_session(conn, email) do
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, @salt, email)
Plug.Conn.put_resp_cookie(conn, @cookie_name, token,
max_age: @max_age,
http_only: true,
secure: Mix.env() == :prod,
same_site: "Lax"
)
end
def get_email(conn) do
with token when is_binary(token) <- conn.cookies[@cookie_name],
{:ok, email} <- Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
{:ok, email}
else
_ -> :error
end
end
def clear_session(conn) do
Plug.Conn.delete_resp_cookie(conn, @cookie_name)
end
end
Plug for LiveView access: lib/berrypod_web/plugs/email_session.ex
Adds email_session to conn assigns so LiveViews can access via @email_session.
Update existing flows:
OrderLookupController.verify/2— callEmailSession.put_session/2Shop.Pages.Orders— checkEmailSessioncookie instead of session- Checkout success — call
EmailSession.put_session/2
Reviews schema
Migration: priv/repo/migrations/xxx_create_reviews.exs
create table(:reviews, primary_key: false) do
add :id, :binary_id, primary_key: true
add :product_id, references(:products, type: :binary_id, on_delete: :delete_all), null: false
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
add :email, :string, null: false
add :author_name, :string, null: false
add :rating, :integer, null: false
add :title, :string
add :body, :text
add :status, :string, default: "pending" # pending, approved, rejected
add :image_ids, {:array, :binary_id}, default: [] # references to images table
timestamps()
end
create unique_index(:reviews, [:email, :product_id])
create index(:reviews, [:product_id, :status])
create index(:reviews, [:status])
Schema: lib/berrypod/reviews/review.ex
Fields:
id— binary_idproduct_id— required, references productsorder_id— optional, for "verified purchase" badge (first matching order)email— required, normalised lowercaseauthor_name— required, display namerating— required, 1-5title— optionalbody— optionalstatus— pending/approved/rejectedimage_ids— array of binary_ids referencing images table (max 3)
Context: lib/berrypod/reviews.ex
Functions:
get_review(id)get_review_by_email_and_product(email, product_id)— for edit checklist_reviews_for_product(product_id, status: :approved)— public displaylist_pending_reviews()— admin queuecreate_review(attrs)— validates purchase, uniquenessupdate_review(review, attrs)— resets status to pendingapprove_review(review)reject_review(review)average_rating_for_product(product_id)— returns {avg, count}can_review?(email, product_id)— checks if email has purchased this productget_review_images(review)— preloads image records for a review
Review photos
Storage approach
Review photos stored in existing images table, referenced by image_ids array in the review. This reuses the existing media/image infrastructure:
- Same upload pipeline
- Same optimisation (Oban jobs)
- Same serving (blob storage)
Not added to the main media library (no media_items record) — just raw images for reviews.
Upload flow
- User selects photos in review form (standard file input, accepts jpg/png/webp/heic)
- Photos uploaded via LiveView
allow_uploadwith 3 file limit - On submit, photos processed through existing image pipeline
- Image IDs stored in review's
image_idsarray - EXIF data stripped for privacy
Display
Extract a generic image_lightbox component from the existing product_lightbox, then create a simpler review_photos component for inline thumbnails.
New component: review_photos in shop_components/content.ex
attr :images, :list, required: true # list of image URLs
attr :review_id, :string, required: true
def review_photos(assigns) do
~H"""
<div :if={@images != []} class="review-photos">
<button
:for={{url, idx} <- Enum.with_index(@images)}
type="button"
class="review-photo-thumb"
phx-click={
JS.exec("data-show", to: "#review-#{@review_id}-lightbox")
|> JS.set_attribute({"data-current-index", to_string(idx)},
to: "#review-#{@review_id}-lightbox")
}
>
<img src={thumb_url(url)} alt="Customer photo" loading="lazy" />
</button>
<.image_lightbox
id={"review-#{@review_id}-lightbox"}
images={@images}
alt="Customer photo"
/>
</div>
"""
end
The existing Lightbox JS hook works unchanged — it's already generic.
Moderation
Photos shown to admin alongside review text during moderation. Admin approves/rejects the whole review (no separate photo moderation).
Review submission flows
1. From product page (email verification)
UI on product page:
+-------------------------------------+
| Write a review |
| |
| [email@example.com ] [Continue] |
| |
| We'll send a link to verify your |
| purchase and let you leave a review |
+-------------------------------------+
Flow:
- User enters email
- System checks: has this email purchased this product?
- No -> "We couldn't find a matching order for this product"
- Yes -> send verification email with review link
- Email contains link:
/reviews/new?token=xxx - Token encodes:
{email, product_id} - On click -> set email session cookie -> show review form
If they already have a valid email session:
- Skip email entry, show review form directly
- Or show "Leave a review as email@example.com" with form
If they've already reviewed this product:
- Show their existing review with "Edit" option
2. From order lookup page
On /orders, for each delivered order line item:
+-------------------------------------+
| Mountain Sunrise Canvas |
| Delivered 15 Jan 2024 |
| |
| [Write a review] or [Edit review] |
+-------------------------------------+
Since they're already verified (have email session), clicking goes straight to review form.
3. Via email request (post-delivery)
Oban job: lib/berrypod/reviews/review_request_job.ex
Scheduled X days after order marked delivered:
- Check if customer has already reviewed all products in order
- Send email with links to review each unreviewed product
- Each link is a signed token for that email+product
Email template:
Subject: How was your order from {shop_name}?
Hi {name},
Your order #{ref} was delivered {days} days ago. We'd love to hear what you think!
{for each product}
[{product_title}] - [Leave a review]
{end}
Thanks for shopping with us!
Admin setting: Days after delivery to send request (default: 7)
Review form
Route: GET /reviews/new?token=xxx or GET /products/:slug/review
LiveView: lib/berrypod_web/live/shop/review_form.ex
Form fields:
- Rating (required) — 5 star selector
- Title (optional) — text input, max 100 chars
- Body (optional) — textarea, max 2000 chars
- Name (required) — pre-filled from order if available, max 50 chars
- Photos (optional) — file upload, max 3 images
Photo upload UI:
- "Add photos (optional)" button with camera icon
- Shows thumbnail previews after selection
- Easy remove button on each thumbnail
- Accepts jpg, png, webp, heic
Validation:
- Rating 1-5
- Title max 100 chars
- Body max 2000 chars
- Name max 50 chars
- Max 3 photos, max 10MB each
On submit:
- Upload and process photos via existing image pipeline
- Create review with status: pending
- Show confirmation: "Thanks! Your review will appear once approved."
- Redirect to product page
Edit mode:
- Same form, pre-filled with existing review
- Existing photos shown with remove option
- Submit updates review, resets status to pending
- Show: "Your updated review will appear once approved."
Reviews display
Product page
Update reviews_section component to accept real data:
# In product page init
reviews = Reviews.list_reviews_for_product(product.id, status: :approved)
{avg, count} = Reviews.average_rating_for_product(product.id)
assign(socket,
reviews: reviews,
average_rating: avg,
review_count: count
)
Component changes:
- Show "Verified purchase" badge if
review.order_idpresent - Show review date as relative time
- Show review photos as clickable thumbnails (using
review_photoscomponent) - Pagination if > 10 reviews
Product cards (optional enhancement)
Show average rating + count on collection/home pages:
****- (24)
Requires preloading aggregate data — consider caching in products table:
rating_avgdecimalrating_countinteger- Updated via Oban job or on review approval
Admin moderation
Route: /admin/reviews
LiveView: lib/berrypod_web/live/admin/reviews.ex
Features:
- Tabs: Pending | Approved | Rejected
- List with: product, author, rating, excerpt, photos (thumbnails), date
- Click to expand full review with full-size photos
- Approve / Reject buttons
- Bulk actions: approve selected, reject selected
Counts in nav:
- Show pending count badge on Reviews nav item
Optional: Email notification to admin on new review
Schema markup
Update product JSON-LD in lib/berrypod_web/components/seo_components.ex:
{
"@type": "Product",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "24",
"bestRating": "5",
"worstRating": "1"
},
"review": [
{
"@type": "Review",
"author": {"@type": "Person", "name": "Jane D."},
"datePublished": "2024-01-15",
"reviewRating": {
"@type": "Rating",
"ratingValue": "5"
},
"reviewBody": "..."
}
]
}
Only include if product has approved reviews.
Task breakdown
Phase 1: Email session foundation (~2h) ✓
-
Email session module (1h) ✓
- Create
Berrypod.EmailSessionmodule - Create plug to load into assigns
- Add to endpoint/router
- Create
-
Update existing flows (1h) ✓
- Order lookup -> set email session on verify
- Orders page -> read from email session
- Checkout success -> set email session (via /checkout/complete controller)
Phase 2: Reviews schema and context (~2.5h) ✓
-
Reviews migration and schema (1h) ✓
- Create migration with image_ids array
- Create
Reviewschema with changeset
-
Reviews context (1.5h) ✓
- CRUD functions
- Query helpers (by product, by email, pending)
- Purchase verification
- Image preloading helpers
Phase 3: Review submission (~4h)
-
Product page review section (1.5h)
- Email entry form (if no session)
- Review form (if session + can review)
- Edit existing review
- Verification email sending
-
Review form LiveView (2h)
- Token verification
- Form with star rating
- Photo upload with LiveView uploads (max 3)
- Create/update handling
- Photo processing via existing pipeline
-
Orders page integration (0.5h)
- "Write a review" / "Edit review" buttons
- Link to review form
Phase 4: Display and components (~2.5h)
-
Extract image_lightbox component (0.5h)
- Generic lightbox from product_lightbox
- Same JS hook, just cleaner component interface
-
Review photos component (0.5h)
- Inline thumbnails
- Opens image_lightbox on click
-
Product page display (1.5h)
- Update reviews_section to use real data
- Verified purchase badge
- Review photos display
- Pagination
Phase 5: Admin moderation (~2h)
-
Admin reviews list (1.5h)
- Reviews list with tabs (pending/approved/rejected)
- Photo thumbnails in list
- Expand to see full review + photos
- Approve/reject actions
-
Nav and notifications (0.5h)
- Pending count badge in admin nav
- Optional: email to admin on new review
Phase 6: Automation and SEO (~2h)
-
Review request emails (1h)
- Oban job for post-delivery requests
- Email template
- Admin setting for delay
-
Schema markup (0.5h)
- AggregateRating on product pages
- Individual Review markup
-
Rating cache on products (0.5h)
- Add rating_avg, rating_count to products
- Update on review approval
- Display on product cards
Files to create
lib/berrypod/email_session.ex— email session token handlinglib/berrypod_web/plugs/email_session.ex— plug to load session into assignspriv/repo/migrations/xxx_create_reviews.exs— reviews tablelib/berrypod/reviews/review.ex— Review schemalib/berrypod/reviews.ex— Reviews contextlib/berrypod/reviews/review_notifier.ex— review verification emailslib/berrypod/reviews/review_request_job.ex— Oban job for post-delivery emailslib/berrypod_web/live/shop/review_form.ex— review submission formlib/berrypod_web/live/admin/reviews.ex— admin moderation
Files to modify
lib/berrypod_web/router.ex— new routeslib/berrypod_web/controllers/order_lookup_controller.ex— set email sessionlib/berrypod_web/live/shop/pages/orders.ex— review buttonslib/berrypod_web/live/shop/pages/product.ex— review section with real datalib/berrypod_web/components/shop_components/product.ex— extract image_lightboxlib/berrypod_web/components/shop_components/content.ex— reviews_section updates, review_photos componentlib/berrypod_web/components/seo_components.ex— review schema markuplib/berrypod/pages/block_types.ex— real data loader for reviewslib/berrypod_web/components/admin_components.ex— nav badge for pending reviewslib/berrypod/products/product.ex— optional rating_avg, rating_count fields
Open questions
- Delay for review request email — 7 days after delivery? Make configurable in admin settings?
- Review replies — can shop owner reply to reviews publicly? (later feature)
- Import reviews — any need to import from other platforms like Etsy?
- Incentives — discount code for leaving a review? (later feature)
- Photo size limit — 10MB per photo reasonable? Or smaller?
Total estimate
~15 hours across 6 phases. Can be broken into sessions:
- Session 1: Phases 1-2 (email session + schema) ~4.5h
- Session 2: Phase 3 (submission flows) ~4h
- Session 3: Phases 4-5 (display + admin) ~4.5h
- Session 4: Phase 6 (automation + SEO) ~2h