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