# Product reviews system

Status: 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`

```elixir
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`

```elixir
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`

```elixir
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:

```elixir
# 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`:

```json
{
  "@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) ✓

3. **Reviews migration and schema** (1h) ✓
   - Create migration with image_ids array
   - Create `Review` schema with changeset

4. **Reviews context** (1.5h) ✓
   - CRUD functions
   - Query helpers (by product, by email, pending)
   - Purchase verification
   - Image preloading helpers

### Phase 3: Review submission (~4h) ✓

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) ✓
   - 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

7. **Orders page integration** (0.5h) ✓
   - "Write a review" / "Edit review" buttons
   - Link to review form

### Phase 4: Display and components (~2.5h) ✓

8. **Extract image_lightbox component** (0.5h) ✓
   - Generic lightbox from product_lightbox
   - Same JS hook, just cleaner component interface

9. **Review photos component** (0.5h) ✓
   - Inline thumbnails with URL building from Image structs
   - Opens image_lightbox on click

10. **Product page display** (1.5h) ✓
    - Updated review_card to include photos and verified badge with icon
    - Verified purchase badge with checkmark SVG
    - Review photos display via review_photos component
    - Pagination via existing load_more_reviews event

### Phase 5: Admin moderation (~2h) ✓

11. **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

12. **Nav and notifications** (0.5h) ✓
    - Pending count badge in admin nav
    - Optional: email to admin on new review

### Phase 6: Automation and SEO (~2h) ✓

13. **Review request emails** (1h) ✓
    - Oban job for post-delivery requests
    - Email template
    - Admin setting for delay (defaults to 7 days)

14. **Schema markup** (0.5h) ✓
    - AggregateRating on product pages
    - Individual Review markup

15. **Rating cache on products** (0.5h) ✓
    - Add rating_avg, rating_count to products
    - Update on review approval/rejection/deletion
    - 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
