berrypod/docs/plans/reviews-system.md
jamey 32eb0c6758
All checks were successful
deploy / deploy (push) Successful in 1m10s
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 <noreply@anthropic.com>
2026-04-01 11:12:25 +01:00

16 KiB

Product reviews system

Status: In Progress (Phase 1-3 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

  1. No customer accounts — email verification only, stored as a browser session
  2. One review per email+product — prevents spam, simplifies UX
  3. Verified purchases only — must have a paid order containing the product
  4. Edit forever — via new magic link if session expired, resets to pending for re-moderation
  5. Moderation by default — reviews start as pending, admin approves
  6. 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_session containing email
  • Cookie lasts 30 days, httponly, secure, same-site lax
  • Also set on checkout completion (via /checkout/complete controller)
  • 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 — call EmailSession.put_session/2
  • Shop.Pages.Orders — check EmailSession cookie 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_id
  • product_id — required, references products
  • order_id — optional, for "verified purchase" badge (first matching order)
  • email — required, normalised lowercase
  • author_name — required, display name
  • rating — required, 1-5
  • title — optional
  • body — optional
  • status — pending/approved/rejected
  • image_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 check
  • list_reviews_for_product(product_id, status: :approved) — public display
  • list_pending_reviews() — admin queue
  • create_review(attrs) — validates purchase, uniqueness
  • update_review(review, attrs) — resets status to pending
  • approve_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 product
  • get_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

  1. User selects photos in review form (standard file input, accepts jpg/png/webp/heic)
  2. Photos uploaded via LiveView allow_upload with 3 file limit
  3. On submit, photos processed through existing image pipeline
  4. Image IDs stored in review's image_ids array
  5. 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:

  1. User enters email
  2. 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
  3. Email contains link: /reviews/new?token=xxx
  4. Token encodes: {email, product_id}
  5. 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_id present
  • Show review date as relative time
  • Show review photos as clickable thumbnails (using review_photos component)
  • 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_avg decimal
  • rating_count integer
  • 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) ✓

  1. Email session module (1h) ✓

    • Create Berrypod.EmailSession module
    • Create plug to load into assigns
    • Add to endpoint/router
  2. 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) ✓

  1. Reviews migration and schema (1h) ✓

    • Create migration with image_ids array
    • Create Review schema with changeset
  2. Reviews context (1.5h) ✓

    • CRUD functions
    • Query helpers (by product, by email, pending)
    • Purchase verification
    • Image preloading helpers

Phase 3: Review submission (~4h) ✓

  1. Product page review section (1.5h) ✓

    • Email entry form (if no session)
    • Review form (if session + can review)
    • Edit existing review
    • Verification email sending
  2. Review form LiveView (2h) ✓

    • Token verification
    • Form with star rating
    • Photo upload with LiveView uploads (max 3) - deferred to Phase 4
    • Create/update handling
    • Photo processing via existing pipeline
  3. Orders page integration (0.5h) ✓

    • "Write a review" / "Edit review" buttons
    • Link to review form

Phase 4: Display and components (~2.5h)

  1. Extract image_lightbox component (0.5h)

    • Generic lightbox from product_lightbox
    • Same JS hook, just cleaner component interface
  2. Review photos component (0.5h)

    • Inline thumbnails
    • Opens image_lightbox on click
  3. Product page display (1.5h)

    • Update reviews_section to use real data
    • Verified purchase badge
    • Review photos display
    • Pagination

Phase 5: Admin moderation (~2h)

  1. 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
  2. Nav and notifications (0.5h)

    • Pending count badge in admin nav
    • Optional: email to admin on new review

Phase 6: Automation and SEO (~2h)

  1. Review request emails (1h)

    • Oban job for post-delivery requests
    • Email template
    • Admin setting for delay
  2. Schema markup (0.5h)

    • AggregateRating on product pages
    • Individual Review markup
  3. 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 handling
  • lib/berrypod_web/plugs/email_session.ex — plug to load session into assigns
  • priv/repo/migrations/xxx_create_reviews.exs — reviews table
  • lib/berrypod/reviews/review.ex — Review schema
  • lib/berrypod/reviews.ex — Reviews context
  • lib/berrypod/reviews/review_notifier.ex — review verification emails
  • lib/berrypod/reviews/review_request_job.ex — Oban job for post-delivery emails
  • lib/berrypod_web/live/shop/review_form.ex — review submission form
  • lib/berrypod_web/live/admin/reviews.ex — admin moderation

Files to modify

  • lib/berrypod_web/router.ex — new routes
  • lib/berrypod_web/controllers/order_lookup_controller.ex — set email session
  • lib/berrypod_web/live/shop/pages/orders.ex — review buttons
  • lib/berrypod_web/live/shop/pages/product.ex — review section with real data
  • lib/berrypod_web/components/shop_components/product.ex — extract image_lightbox
  • lib/berrypod_web/components/shop_components/content.ex — reviews_section updates, review_photos component
  • lib/berrypod_web/components/seo_components.ex — review schema markup
  • lib/berrypod/pages/block_types.ex — real data loader for reviews
  • lib/berrypod_web/components/admin_components.ex — nav badge for pending reviews
  • lib/berrypod/products/product.ex — optional rating_avg, rating_count fields

Open questions

  1. Delay for review request email — 7 days after delivery? Make configurable in admin settings?
  2. Review replies — can shop owner reply to reviews publicly? (later feature)
  3. Import reviews — any need to import from other platforms like Etsy?
  4. Incentives — discount code for leaving a review? (later feature)
  5. 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