diff --git a/docs/plans/reviews-system.md b/docs/plans/reviews-system.md
new file mode 100644
index 0000000..b06e9dd
--- /dev/null
+++ b/docs/plans/reviews-system.md
@@ -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"""
+
+
+
+ <.image_lightbox
+ id={"review-#{@review_id}-lightbox"}
+ images={@images}
+ alt="Customer photo"
+ />
+
+ """
+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
diff --git a/lib/berrypod/email_session.ex b/lib/berrypod/email_session.ex
new file mode 100644
index 0000000..f28c8d3
--- /dev/null
+++ b/lib/berrypod/email_session.ex
@@ -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
diff --git a/lib/berrypod_web/controllers/checkout_controller.ex b/lib/berrypod_web/controllers/checkout_controller.ex
index 6fb5f87..e3a6503 100644
--- a/lib/berrypod_web/controllers/checkout_controller.ex
+++ b/lib/berrypod_web/controllers/checkout_controller.ex
@@ -71,7 +71,7 @@ defmodule BerrypodWeb.CheckoutController do
%{
mode: "payment",
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()),
metadata: %{"order_id" => order.id},
shipping_address_collection: %{
diff --git a/lib/berrypod_web/controllers/checkout_success_controller.ex b/lib/berrypod_web/controllers/checkout_success_controller.ex
new file mode 100644
index 0000000..98ad39e
--- /dev/null
+++ b/lib/berrypod_web/controllers/checkout_success_controller.ex
@@ -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
diff --git a/lib/berrypod_web/controllers/order_lookup_controller.ex b/lib/berrypod_web/controllers/order_lookup_controller.ex
index 62efc0c..31e8046 100644
--- a/lib/berrypod_web/controllers/order_lookup_controller.ex
+++ b/lib/berrypod_web/controllers/order_lookup_controller.ex
@@ -1,6 +1,7 @@
defmodule BerrypodWeb.OrderLookupController do
use BerrypodWeb, :controller
+ alias Berrypod.EmailSession
alias Berrypod.Orders
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
{:ok, email} ->
conn
- |> put_session(:order_lookup_email, email)
+ |> EmailSession.put_session(email)
|> redirect(to: R.orders())
{:error, :expired} ->
diff --git a/lib/berrypod_web/live/shop/pages/order_detail.ex b/lib/berrypod_web/live/shop/pages/order_detail.ex
index d161316..de22851 100644
--- a/lib/berrypod_web/live/shop/pages/order_detail.ex
+++ b/lib/berrypod_web/live/shop/pages/order_detail.ex
@@ -16,7 +16,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
socket =
socket
- |> assign(:lookup_email, session["order_lookup_email"])
+ |> assign(:lookup_email, session["email_session"])
|> assign(:page, page)
{:noreply, socket}
diff --git a/lib/berrypod_web/live/shop/pages/orders.ex b/lib/berrypod_web/live/shop/pages/orders.ex
index b3c3852..69f0e98 100644
--- a/lib/berrypod_web/live/shop/pages/orders.ex
+++ b/lib/berrypod_web/live/shop/pages/orders.ex
@@ -1,6 +1,9 @@
defmodule BerrypodWeb.Shop.Pages.Orders do
@moduledoc """
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]
@@ -8,7 +11,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
alias Berrypod.{Orders, Pages}
def init(socket, _params, _uri, session) do
- email = session["order_lookup_email"]
+ email = session["email_session"]
page = Pages.get_page("orders")
socket =
diff --git a/lib/berrypod_web/plugs/email_session.ex b/lib/berrypod_web/plugs/email_session.ex
new file mode 100644
index 0000000..72130cd
--- /dev/null
+++ b/lib/berrypod_web/plugs/email_session.ex
@@ -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
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index b8113a5..7d916a0 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -14,6 +14,7 @@ defmodule BerrypodWeb.Router do
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
+ plug BerrypodWeb.Plugs.EmailSession
plug BerrypodWeb.Plugs.CountryDetect
plug BerrypodWeb.Plugs.LoadTheme
end
@@ -219,10 +220,12 @@ defmodule BerrypodWeb.Router do
end
# Order lookup verification — sets session email then redirects to /orders
+ # Checkout complete — sets email session cookie then redirects to success page
scope "/", BerrypodWeb do
pipe_through [:browser]
get "/orders/verify/:token", OrderLookupController, :verify
+ get "/checkout/complete", CheckoutSuccessController, :show
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
get "/newsletter/confirm/:token", NewsletterController, :confirm
end
diff --git a/test/berrypod/email_session_test.exs b/test/berrypod/email_session_test.exs
new file mode 100644
index 0000000..da4b00c
--- /dev/null
+++ b/test/berrypod/email_session_test.exs
@@ -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
diff --git a/test/berrypod_web/controllers/checkout_success_controller_test.exs b/test/berrypod_web/controllers/checkout_success_controller_test.exs
new file mode 100644
index 0000000..283668e
--- /dev/null
+++ b/test/berrypod_web/controllers/checkout_success_controller_test.exs
@@ -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
diff --git a/test/berrypod_web/controllers/order_lookup_controller_test.exs b/test/berrypod_web/controllers/order_lookup_controller_test.exs
index 5f71866..e7bbf45 100644
--- a/test/berrypod_web/controllers/order_lookup_controller_test.exs
+++ b/test/berrypod_web/controllers/order_lookup_controller_test.exs
@@ -4,6 +4,8 @@ defmodule BerrypodWeb.OrderLookupControllerTest do
import Berrypod.AccountsFixtures
import Berrypod.OrdersFixtures
+ alias Berrypod.EmailSession
+
setup do
user_fixture()
{: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"
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
diff --git a/test/berrypod_web/live/shop/orders_test.exs b/test/berrypod_web/live/shop/orders_test.exs
index b041df2..bb0b9f0 100644
--- a/test/berrypod_web/live/shop/orders_test.exs
+++ b/test/berrypod_web/live/shop/orders_test.exs
@@ -14,7 +14,7 @@ defmodule BerrypodWeb.Shop.OrdersTest do
defp with_lookup_email(conn, email) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
- |> Plug.Conn.put_session("order_lookup_email", email)
+ |> Plug.Conn.put_session("email_session", email)
end
describe "orders list — no session email" do