add persistent email session for order lookup and reviews
All checks were successful
deploy / deploy (push) Successful in 1m13s
All checks were successful
deploy / deploy (push) Successful in 1m13s
Replaces the short-lived (1 hour) session-based order lookup with a persistent cookie-based email session lasting 30 days. This foundation enables customers to leave reviews and view orders without re-verifying their email each time. - Add EmailSession module for signed cookie management - Add EmailSession plug to load verified email into session - Set email session on order lookup verification - Set email session on checkout completion (via /checkout/complete) - Update orders and order detail pages to use email session - Add reviews system plan document Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b23a413ed
commit
34822254e3
540
docs/plans/reviews-system.md
Normal file
540
docs/plans/reviews-system.md
Normal file
@ -0,0 +1,540 @@
|
|||||||
|
# Product reviews system
|
||||||
|
|
||||||
|
Status: In Progress (Phase 1 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)
|
||||||
|
- 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
|
||||||
|
- Opens image_lightbox on click
|
||||||
|
|
||||||
|
10. **Product page display** (1.5h)
|
||||||
|
- Update reviews_section to use real data
|
||||||
|
- Verified purchase badge
|
||||||
|
- Review photos display
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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
|
||||||
|
- 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
|
||||||
67
lib/berrypod/email_session.ex
Normal file
67
lib/berrypod/email_session.ex
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
defmodule Berrypod.EmailSession do
|
||||||
|
@moduledoc """
|
||||||
|
Manages persistent email sessions for verified customers.
|
||||||
|
|
||||||
|
Used for order lookup, review submission, and review editing without
|
||||||
|
requiring re-verification each time. The session is stored as a signed
|
||||||
|
cookie that lasts 30 days.
|
||||||
|
|
||||||
|
Unlike the short-lived order lookup tokens (1 hour), this provides a
|
||||||
|
"remember me" style experience for returning customers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
@cookie_name "email_session"
|
||||||
|
@max_age 30 * 24 * 60 * 60
|
||||||
|
@salt "email_session_v1"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets the email session cookie for a verified email address.
|
||||||
|
|
||||||
|
Call this after successful email verification (order lookup, review
|
||||||
|
submission, checkout completion) to enable the customer to access
|
||||||
|
their orders and reviews without re-verifying.
|
||||||
|
"""
|
||||||
|
def put_session(conn, email) when is_binary(email) do
|
||||||
|
email = String.downcase(String.trim(email))
|
||||||
|
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, @salt, email)
|
||||||
|
|
||||||
|
put_resp_cookie(conn, @cookie_name, token,
|
||||||
|
max_age: @max_age,
|
||||||
|
http_only: true,
|
||||||
|
secure: Application.get_env(:berrypod, :env) == :prod,
|
||||||
|
same_site: "Lax"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Retrieves the verified email address from the session cookie.
|
||||||
|
|
||||||
|
Returns `{:ok, email}` if the cookie is valid and not expired,
|
||||||
|
or `:error` otherwise.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Clears the email session cookie.
|
||||||
|
|
||||||
|
Use when the customer explicitly logs out or requests to be forgotten.
|
||||||
|
"""
|
||||||
|
def clear_session(conn) do
|
||||||
|
delete_resp_cookie(conn, @cookie_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the cookie name for testing purposes.
|
||||||
|
"""
|
||||||
|
def cookie_name, do: @cookie_name
|
||||||
|
end
|
||||||
@ -71,7 +71,7 @@ defmodule BerrypodWeb.CheckoutController do
|
|||||||
%{
|
%{
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
line_items: line_items,
|
line_items: line_items,
|
||||||
success_url: R.url(R.checkout_success()) <> "?session_id={CHECKOUT_SESSION_ID}",
|
success_url: R.url("/checkout/complete") <> "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
cancel_url: R.url(R.cart()),
|
cancel_url: R.url(R.cart()),
|
||||||
metadata: %{"order_id" => order.id},
|
metadata: %{"order_id" => order.id},
|
||||||
shipping_address_collection: %{
|
shipping_address_collection: %{
|
||||||
|
|||||||
32
lib/berrypod_web/controllers/checkout_success_controller.ex
Normal file
32
lib/berrypod_web/controllers/checkout_success_controller.ex
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
defmodule BerrypodWeb.CheckoutSuccessController do
|
||||||
|
@moduledoc """
|
||||||
|
Handles the redirect back from Stripe checkout.
|
||||||
|
|
||||||
|
This controller intercepts the Stripe redirect to set the email session
|
||||||
|
cookie before forwarding to the checkout success LiveView. This allows
|
||||||
|
customers to later view their orders and leave reviews without needing
|
||||||
|
to re-verify their email.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.{EmailSession, Orders}
|
||||||
|
|
||||||
|
def show(conn, %{"session_id" => session_id}) do
|
||||||
|
# Look up the order to get the customer email
|
||||||
|
order = Orders.get_order_by_stripe_session(session_id)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
if order && order.customer_email do
|
||||||
|
EmailSession.put_session(conn, order.customer_email)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect(conn, to: R.checkout_success() <> "?session_id=#{session_id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def show(conn, _params) do
|
||||||
|
redirect(conn, to: R.home())
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,6 +1,7 @@
|
|||||||
defmodule BerrypodWeb.OrderLookupController do
|
defmodule BerrypodWeb.OrderLookupController do
|
||||||
use BerrypodWeb, :controller
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.EmailSession
|
||||||
alias Berrypod.Orders
|
alias Berrypod.Orders
|
||||||
alias Berrypod.Orders.OrderNotifier
|
alias Berrypod.Orders.OrderNotifier
|
||||||
|
|
||||||
@ -44,7 +45,7 @@ defmodule BerrypodWeb.OrderLookupController do
|
|||||||
case Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
|
case Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
|
||||||
{:ok, email} ->
|
{:ok, email} ->
|
||||||
conn
|
conn
|
||||||
|> put_session(:order_lookup_email, email)
|
|> EmailSession.put_session(email)
|
||||||
|> redirect(to: R.orders())
|
|> redirect(to: R.orders())
|
||||||
|
|
||||||
{:error, :expired} ->
|
{:error, :expired} ->
|
||||||
|
|||||||
@ -16,7 +16,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
|
|||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:lookup_email, session["order_lookup_email"])
|
|> assign(:lookup_email, session["email_session"])
|
||||||
|> assign(:page, page)
|
|> assign(:page, page)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
defmodule BerrypodWeb.Shop.Pages.Orders do
|
defmodule BerrypodWeb.Shop.Pages.Orders do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Orders list page handler for the unified Shop.Page LiveView.
|
Orders list page handler for the unified Shop.Page LiveView.
|
||||||
|
|
||||||
|
Uses the email session cookie (30 days) set during order lookup
|
||||||
|
verification or checkout completion.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
@ -8,7 +11,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
|
|||||||
alias Berrypod.{Orders, Pages}
|
alias Berrypod.{Orders, Pages}
|
||||||
|
|
||||||
def init(socket, _params, _uri, session) do
|
def init(socket, _params, _uri, session) do
|
||||||
email = session["order_lookup_email"]
|
email = session["email_session"]
|
||||||
page = Pages.get_page("orders")
|
page = Pages.get_page("orders")
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|||||||
26
lib/berrypod_web/plugs/email_session.ex
Normal file
26
lib/berrypod_web/plugs/email_session.ex
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
defmodule BerrypodWeb.Plugs.EmailSession do
|
||||||
|
@moduledoc """
|
||||||
|
Plug that loads the verified email from the email session cookie into assigns.
|
||||||
|
|
||||||
|
This makes `@email_session` available in controllers and LiveViews,
|
||||||
|
containing the verified email address if the customer has one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
alias Berrypod.EmailSession
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
case EmailSession.get_email(conn) do
|
||||||
|
{:ok, email} ->
|
||||||
|
conn
|
||||||
|
|> assign(:email_session, email)
|
||||||
|
|> put_session("email_session", email)
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
assign(conn, :email_session, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -14,6 +14,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :fetch_current_scope_for_user
|
plug :fetch_current_scope_for_user
|
||||||
|
plug BerrypodWeb.Plugs.EmailSession
|
||||||
plug BerrypodWeb.Plugs.CountryDetect
|
plug BerrypodWeb.Plugs.CountryDetect
|
||||||
plug BerrypodWeb.Plugs.LoadTheme
|
plug BerrypodWeb.Plugs.LoadTheme
|
||||||
end
|
end
|
||||||
@ -219,10 +220,12 @@ defmodule BerrypodWeb.Router do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Order lookup verification — sets session email then redirects to /orders
|
# Order lookup verification — sets session email then redirects to /orders
|
||||||
|
# Checkout complete — sets email session cookie then redirects to success page
|
||||||
scope "/", BerrypodWeb do
|
scope "/", BerrypodWeb do
|
||||||
pipe_through [:browser]
|
pipe_through [:browser]
|
||||||
|
|
||||||
get "/orders/verify/:token", OrderLookupController, :verify
|
get "/orders/verify/:token", OrderLookupController, :verify
|
||||||
|
get "/checkout/complete", CheckoutSuccessController, :show
|
||||||
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||||
get "/newsletter/confirm/:token", NewsletterController, :confirm
|
get "/newsletter/confirm/:token", NewsletterController, :confirm
|
||||||
end
|
end
|
||||||
|
|||||||
64
test/berrypod/email_session_test.exs
Normal file
64
test/berrypod/email_session_test.exs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
defmodule Berrypod.EmailSessionTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias Berrypod.EmailSession
|
||||||
|
|
||||||
|
describe "put_session/2" do
|
||||||
|
test "sets a signed cookie with the email", %{conn: conn} do
|
||||||
|
conn = EmailSession.put_session(conn, "test@example.com")
|
||||||
|
|
||||||
|
assert cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
assert cookie.max_age == 30 * 24 * 60 * 60
|
||||||
|
assert cookie.http_only == true
|
||||||
|
assert cookie.same_site == "Lax"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normalises email to lowercase", %{conn: conn} do
|
||||||
|
conn = EmailSession.put_session(conn, "TEST@EXAMPLE.COM")
|
||||||
|
cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
|
||||||
|
# The cookie value is a signed token, so we verify by getting it back
|
||||||
|
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
|
||||||
|
assert {:ok, "test@example.com"} = EmailSession.get_email(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "trims whitespace from email", %{conn: conn} do
|
||||||
|
conn = EmailSession.put_session(conn, " test@example.com ")
|
||||||
|
cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
|
||||||
|
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
|
||||||
|
assert {:ok, "test@example.com"} = EmailSession.get_email(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_email/1" do
|
||||||
|
test "returns the email from a valid cookie", %{conn: conn} do
|
||||||
|
conn = EmailSession.put_session(conn, "buyer@shop.com")
|
||||||
|
cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
|
||||||
|
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
|
||||||
|
assert {:ok, "buyer@shop.com"} = EmailSession.get_email(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :error when no cookie present", %{conn: conn} do
|
||||||
|
conn = %{conn | cookies: %{}}
|
||||||
|
assert :error = EmailSession.get_email(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :error for invalid token", %{conn: conn} do
|
||||||
|
conn = %{conn | cookies: %{EmailSession.cookie_name() => "invalid-token"}}
|
||||||
|
assert :error = EmailSession.get_email(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "clear_session/1" do
|
||||||
|
test "deletes the cookie", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> EmailSession.put_session("test@example.com")
|
||||||
|
|> EmailSession.clear_session()
|
||||||
|
|
||||||
|
assert conn.resp_cookies[EmailSession.cookie_name()].max_age == 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
defmodule BerrypodWeb.CheckoutSuccessControllerTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Berrypod.AccountsFixtures
|
||||||
|
import Berrypod.OrdersFixtures
|
||||||
|
|
||||||
|
alias Berrypod.{EmailSession, Orders}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user_fixture()
|
||||||
|
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /checkout/complete" do
|
||||||
|
test "sets email session cookie and redirects to success page when order found", %{conn: conn} do
|
||||||
|
order = order_fixture(%{customer_email: "buyer@test.com"})
|
||||||
|
{:ok, order} = Orders.set_stripe_session(order, "cs_test_123")
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/checkout/complete", %{"session_id" => order.stripe_session_id})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/checkout/success?session_id=cs_test_123"
|
||||||
|
|
||||||
|
# Verify the email session cookie was set
|
||||||
|
cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
assert cookie != nil
|
||||||
|
assert cookie.max_age == 30 * 24 * 60 * 60
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects without setting cookie when order not found", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/checkout/complete", %{"session_id" => "nonexistent"})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/checkout/success?session_id=nonexistent"
|
||||||
|
|
||||||
|
# No cookie should be set
|
||||||
|
assert conn.resp_cookies[EmailSession.cookie_name()] == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to home when no session_id provided", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/checkout/complete")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -4,6 +4,8 @@ defmodule BerrypodWeb.OrderLookupControllerTest do
|
|||||||
import Berrypod.AccountsFixtures
|
import Berrypod.AccountsFixtures
|
||||||
import Berrypod.OrdersFixtures
|
import Berrypod.OrdersFixtures
|
||||||
|
|
||||||
|
alias Berrypod.EmailSession
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
user_fixture()
|
user_fixture()
|
||||||
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||||
@ -34,4 +36,27 @@ defmodule BerrypodWeb.OrderLookupControllerTest do
|
|||||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "enter your email"
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "enter your email"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "GET /orders/verify/:token" do
|
||||||
|
test "sets email session cookie and redirects to orders page", %{conn: conn} do
|
||||||
|
order_fixture(%{customer_email: "buyer@test.com", payment_status: "paid"})
|
||||||
|
token = BerrypodWeb.OrderLookupController.generate_token("buyer@test.com")
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/orders/verify/#{token}")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/orders"
|
||||||
|
|
||||||
|
# Verify the email session cookie was set
|
||||||
|
cookie = conn.resp_cookies[EmailSession.cookie_name()]
|
||||||
|
assert cookie != nil
|
||||||
|
assert cookie.max_age == 30 * 24 * 60 * 60
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid token", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/orders/verify/invalid-token")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/contact"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -14,7 +14,7 @@ defmodule BerrypodWeb.Shop.OrdersTest do
|
|||||||
defp with_lookup_email(conn, email) do
|
defp with_lookup_email(conn, email) do
|
||||||
conn
|
conn
|
||||||
|> Phoenix.ConnTest.init_test_session(%{})
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|> Plug.Conn.put_session("order_lookup_email", email)
|
|> Plug.Conn.put_session("email_session", email)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "orders list — no session email" do
|
describe "orders list — no session email" do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user