From 01ff8decd5ac6abe6821bde33c5144bdad2d2e7b Mon Sep 17 00:00:00 2001 From: jamey Date: Tue, 24 Feb 2026 08:40:08 +0000 Subject: [PATCH] add order status lookup for customers Magic link flow on contact page: customer enters email, gets a time-limited signed link, clicks through to /orders showing all their paid orders and full detail pages with thumbnails and product links. - OrderLookupController generates/verifies Phoenix.Token signed links - Contact LiveView handles lookup_orders + reset_tracking events - Orders and OrderDetail LiveViews gated by session email - Order detail shows thumbnails, links to products still available - .themed-button gets base padding/font-weight so all usages are consistent - order-summary-card sticky scoped to .cart-grid (was leaking to orders list) - 27 new tests (1095 total) Co-Authored-By: Claude Sonnet 4.6 --- PROGRESS.md | 2 +- assets/css/shop/components.css | 254 ++++++++++++++++++ assets/css/theme-layer2-attributes.css | 4 + lib/berrypod/orders.ex | 16 ++ lib/berrypod/orders/order_notifier.ex | 25 ++ lib/berrypod_web/components/page_templates.ex | 9 + .../page_templates/contact.html.heex | 2 +- .../page_templates/order_detail.html.heex | 120 +++++++++ .../page_templates/orders.html.heex | 71 +++++ .../components/shop_components/content.ex | 61 ++++- .../controllers/order_lookup_controller.ex | 32 +++ lib/berrypod_web/live/shop/contact.ex | 29 +- lib/berrypod_web/live/shop/order_detail.ex | 55 ++++ lib/berrypod_web/live/shop/orders.ex | 34 +++ lib/berrypod_web/router.ex | 9 + test/berrypod/orders/order_notifier_test.exs | 14 + test/berrypod/orders_test.exs | 59 ++++ test/berrypod_web/live/shop/contact_test.exs | 97 +++++++ test/berrypod_web/live/shop/orders_test.exs | 145 ++++++++++ 19 files changed, 1030 insertions(+), 8 deletions(-) create mode 100644 lib/berrypod_web/components/page_templates/order_detail.html.heex create mode 100644 lib/berrypod_web/components/page_templates/orders.html.heex create mode 100644 lib/berrypod_web/controllers/order_lookup_controller.ex create mode 100644 lib/berrypod_web/live/shop/order_detail.ex create mode 100644 lib/berrypod_web/live/shop/orders.ex create mode 100644 test/berrypod_web/live/shop/contact_test.exs create mode 100644 test/berrypod_web/live/shop/orders_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index 5f6f872..36177f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -128,7 +128,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned | | 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned | | | **Other features** | | | | -| 72 | Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt) | — | 1.5h | planned | +| ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done | | | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | | | 75 | Handle `checkout.session.expired` webhook, store abandoned cart record | — | 1h | planned | | 76 | Send single recovery email (plain text, no tracking, clear opt-out) | 75 | 1h | planned | diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index 079c044..ee2f385 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -1545,6 +1545,9 @@ .order-summary-card { padding: 1.5rem; + } + + .cart-grid .order-summary-card { position: sticky; top: 1rem; } @@ -2217,6 +2220,7 @@ .checkout-item { display: flex; + gap: 0.75rem; justify-content: space-between; align-items: flex-start; padding-bottom: 1rem; @@ -2228,11 +2232,29 @@ } } + .checkout-item-thumb { + width: 3.5rem; + height: 3.5rem; + object-fit: cover; + border-radius: var(--t-radius-card); + flex-shrink: 0; + } + + .checkout-item > div { + flex: 1; + } + .checkout-item-name { font-weight: 500; color: var(--t-text-primary); } + .checkout-item-link { + text-decoration: none; + + &:hover { text-decoration: underline; } + } + .checkout-item-detail { font-size: var(--t-text-small, 0.875rem); color: var(--t-text-secondary); @@ -2517,3 +2539,235 @@ color: var(--t-text-secondary); } } + +@layer components { + /* ========================================================= + Orders list page + ========================================================= */ + + .orders-main { + max-width: 48rem; + padding-block: 4rem; + } + + .orders-header { + margin-bottom: 2rem; + } + + .orders-page-title { + font-family: var(--t-font-heading); + font-size: var(--t-text-3xl, 1.875rem); + font-weight: 700; + color: var(--t-text-primary); + margin-bottom: 0.5rem; + } + + .orders-email-label { + color: var(--t-text-secondary); + margin-bottom: 0.5rem; + + & strong { color: var(--t-text-primary); } + } + + .orders-search-again { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-accent); + text-decoration: underline; + } + + .orders-empty { + color: var(--t-text-secondary); + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .orders-empty-hint { + font-size: var(--t-text-small, 0.875rem); + } + + .orders-contact-link { + color: var(--t-accent); + text-decoration: underline; + font-size: var(--t-text-small, 0.875rem); + } + + .orders-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .order-summary-card { + display: block; + padding: 1.25rem 1.5rem; + border: 1px solid var(--t-border-default); + border-radius: var(--t-radius-card); + background-color: var(--t-surface-card); + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:hover { + border-color: var(--t-accent); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.08); + } + } + + .order-summary-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + gap: 1rem; + } + + .order-summary-number { + font-weight: 600; + color: var(--t-text-primary); + font-size: var(--t-text-base, 1rem); + } + + .order-summary-date { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-text-secondary); + margin-top: 0.2rem; + } + + .order-summary-items { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .order-summary-item { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-text-secondary); + } + + .order-summary-variant { + color: var(--t-text-tertiary, var(--t-text-secondary)); + } + + .order-summary-more { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-text-tertiary, var(--t-text-secondary)); + font-style: italic; + } + + .order-summary-footer { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--t-border-default); + padding-top: 0.75rem; + } + + .order-summary-total { + font-weight: 600; + color: var(--t-text-primary); + } + + .order-summary-arrow { + color: var(--t-text-secondary); + font-size: 1.1rem; + } + + /* Status badge — shared between list + detail */ + .order-status-badge { + display: inline-block; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: var(--t-text-xs, 0.75rem); + font-weight: 600; + white-space: nowrap; + background-color: var(--t-surface-sunken); + color: var(--t-text-secondary); + + &--lg { + font-size: var(--t-text-small, 0.875rem); + padding: 0.375rem 0.875rem; + margin-top: 0.75rem; + } + + &--shipped, + &--delivered { + background-color: color-mix(in srgb, var(--t-accent) 15%, transparent); + color: var(--t-accent); + } + + &--failed { + background-color: color-mix(in srgb, #ef4444 12%, transparent); + color: #b91c1c; + } + } + + /* ========================================================= + Order detail page + ========================================================= */ + + .order-detail-main { + max-width: 48rem; + padding-block: 4rem; + } + + .order-detail-header { + margin-bottom: 2rem; + } + + .order-detail-back { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-accent); + text-decoration: underline; + } + + .order-detail-tracking-card { + margin-bottom: 1.5rem; + padding: 1.25rem 1.5rem; + } + + .order-detail-tracking { + display: flex; + align-items: center; + gap: 1rem; + + & svg { + flex-shrink: 0; + color: var(--t-text-secondary); + } + } + + .order-detail-tracking-label { + font-size: var(--t-text-small, 0.875rem); + font-weight: 600; + color: var(--t-text-primary); + } + + .order-detail-tracking-number { + font-size: var(--t-text-small, 0.875rem); + color: var(--t-text-secondary); + margin-top: 0.125rem; + } + + .order-detail-tracking-btn { + margin-left: auto; + flex-shrink: 0; + padding: 0.5rem 1rem; + font-size: var(--t-text-small, 0.875rem); + } +} + +@layer components { + .order-tracking-reset { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: var(--t-text-small, 0.875rem); + color: var(--t-accent); + text-decoration: underline; + } +} diff --git a/assets/css/theme-layer2-attributes.css b/assets/css/theme-layer2-attributes.css index 396ca96..74d4b89 100644 --- a/assets/css/theme-layer2-attributes.css +++ b/assets/css/theme-layer2-attributes.css @@ -281,6 +281,8 @@ border-radius: var(--t-radius-button); border: none; cursor: pointer; + padding: 0.75rem 1.5rem; + font-weight: 600; } & .themed-button-outline { @@ -289,6 +291,8 @@ border: 1px solid var(--t-border-default); border-radius: var(--t-radius-button); cursor: pointer; + padding: 0.75rem 1.5rem; + font-weight: 600; } & .themed-card { diff --git a/lib/berrypod/orders.ex b/lib/berrypod/orders.ex index 22f56c0..d16b6f6 100644 --- a/lib/berrypod/orders.ex +++ b/lib/berrypod/orders.ex @@ -287,6 +287,22 @@ defmodule Berrypod.Orders do |> Repo.all() end + @doc """ + Lists paid orders for a given customer email, newest first, with items preloaded. + + Only returns paid orders — pending/failed orders aren't useful to show customers. + """ + def list_orders_by_email(email) when is_binary(email) do + normalised = String.downcase(String.trim(email)) + + Order + |> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised) + |> where([o], o.payment_status == "paid") + |> order_by([o], desc: o.inserted_at) + |> preload(:items) + |> Repo.all() + end + @doc """ Gets an order by its order number. """ diff --git a/lib/berrypod/orders/order_notifier.ex b/lib/berrypod/orders/order_notifier.ex index 8f8e610..f2473fb 100644 --- a/lib/berrypod/orders/order_notifier.ex +++ b/lib/berrypod/orders/order_notifier.ex @@ -42,6 +42,31 @@ defmodule Berrypod.Orders.OrderNotifier do deliver(order.customer_email, subject, body) end + @doc """ + Sends a magic link for the customer to view their orders. + + The link is time-limited (controlled by the caller's token expiry). + """ + def deliver_order_lookup(email, link) do + subject = "Your order lookup link" + + body = """ + ============================== + + Here's your link to view your orders: + + #{link} + + This link expires in 1 hour. + + If you didn't request this, you can ignore this email. + + ============================== + """ + + deliver(email, subject, body) + end + @doc """ Sends a shipping notification with tracking info. diff --git a/lib/berrypod_web/components/page_templates.ex b/lib/berrypod_web/components/page_templates.ex index 454bd00..2b75b27 100644 --- a/lib/berrypod_web/components/page_templates.ex +++ b/lib/berrypod_web/components/page_templates.ex @@ -18,4 +18,13 @@ defmodule BerrypodWeb.PageTemplates do use BerrypodWeb.ShopComponents embed_templates "page_templates/*" + + def format_order_status("unfulfilled"), do: "Being prepared" + def format_order_status("submitted"), do: "Sent to printer" + def format_order_status("processing"), do: "In production" + def format_order_status("shipped"), do: "On its way" + def format_order_status("delivered"), do: "Delivered" + def format_order_status("failed"), do: "Issue — contact us" + def format_order_status("cancelled"), do: "Cancelled" + def format_order_status(status), do: status end diff --git a/lib/berrypod_web/components/page_templates/contact.html.heex b/lib/berrypod_web/components/page_templates/contact.html.heex index d70e05f..9fb2ffa 100644 --- a/lib/berrypod_web/components/page_templates/contact.html.heex +++ b/lib/berrypod_web/components/page_templates/contact.html.heex @@ -10,7 +10,7 @@ <.contact_form email="hello@example.com" />
- <.order_tracking_card /> + <.order_tracking_card tracking_state={assigns[:tracking_state] || :idle} /> <.info_card title="Handy to know" diff --git a/lib/berrypod_web/components/page_templates/order_detail.html.heex b/lib/berrypod_web/components/page_templates/order_detail.html.heex new file mode 100644 index 0000000..caf0bc9 --- /dev/null +++ b/lib/berrypod_web/components/page_templates/order_detail.html.heex @@ -0,0 +1,120 @@ +<.shop_layout {layout_assigns(assigns)} active_page="contact"> +
+ <%= if @order do %> +
+ <.link navigate="/orders" class="order-detail-back"> + ← Back to orders + +

{@order.order_number}

+

+ {Calendar.strftime(@order.inserted_at, "%-d %B %Y")} +

+ + {format_order_status(@order.fulfilment_status)} + +
+ + <%= if @order.tracking_number || @order.tracking_url do %> + <.shop_card class="order-detail-tracking-card"> +
+ + + +
+

Shipment tracking

+ <%= if @order.tracking_number do %> +

{@order.tracking_number}

+ <% end %> +
+ <%= if @order.tracking_url do %> + + Track parcel + + <% end %> +
+ + <% end %> + + <.shop_card class="checkout-card"> +

Items ordered

+
    + <%= for item <- @order.items do %> + <% info = @thumbnails[item.variant_id] %> +
  • + <%= if info && info.thumb do %> + {item.product_name} + <% end %> +
    + <%= if info && info.slug do %> + <.link + navigate={"/products/#{info.slug}"} + class="checkout-item-name checkout-item-link" + > + {item.product_name} + + <% else %> +

    {item.product_name}

    + <% end %> + <%= if item.variant_title && item.variant_title != "" do %> +

    {item.variant_title}

    + <% end %> +

    Qty: {item.quantity}

    +
    + + {Berrypod.Cart.format_price(item.unit_price * item.quantity)} + +
  • + <% end %> +
+ +
+
+ Total + + {Berrypod.Cart.format_price(@order.total)} + +
+
+ + + <%= if @order.shipping_address != %{} do %> + <.shop_card class="checkout-card"> +

Shipping to

+
+

{@order.shipping_address["name"]}

+

{@order.shipping_address["line1"]}

+ <%= if @order.shipping_address["line2"] do %> +

{@order.shipping_address["line2"]}

+ <% end %> +

+ {@order.shipping_address["city"]}, {@order.shipping_address["postal_code"]} +

+

{@order.shipping_address["country"]}

+
+ + <% end %> + +
+ <.shop_link_button href="/collections/all"> + Continue shopping + +
+ <% end %> +
+ diff --git a/lib/berrypod_web/components/page_templates/orders.html.heex b/lib/berrypod_web/components/page_templates/orders.html.heex new file mode 100644 index 0000000..e1d1b40 --- /dev/null +++ b/lib/berrypod_web/components/page_templates/orders.html.heex @@ -0,0 +1,71 @@ +<.shop_layout {layout_assigns(assigns)} active_page="contact"> +
+
+

Your orders

+ + <%= if @lookup_email do %> +

+ Orders for {@lookup_email} +

+ <% end %> +
+ + <%= cond do %> + <% is_nil(@orders) -> %> +
+

This link has expired or is invalid.

+

+ Head back to the <.link navigate="/contact">contact page to request a new one. +

+
+ <% @orders == [] -> %> +
+

No orders found for that email address.

+

+ If something doesn't look right, <.link navigate="/contact">get in touch. +

+
+ <% true -> %> +
+ <%= for order <- @orders do %> + <.link navigate={"/orders/#{order.order_number}"} class="order-summary-card"> +
+
+

{order.order_number}

+

+ {Calendar.strftime(order.inserted_at, "%-d %B %Y")} +

+
+ + {format_order_status(order.fulfilment_status)} + +
+ +
    + <%= for item <- Enum.take(order.items, 2) do %> +
  • + {item.quantity}× {item.product_name} + <%= if item.variant_title && item.variant_title != "" do %> + · {item.variant_title} + <% end %> +
  • + <% end %> + <%= if length(order.items) > 2 do %> +
  • + +{length(order.items) - 2} more +
  • + <% end %> +
+ + + + <% end %> +
+ <% end %> +
+ diff --git a/lib/berrypod_web/components/shop_components/content.ex b/lib/berrypod_web/components/shop_components/content.ex index 83c6a49..13f6779 100644 --- a/lib/berrypod_web/components/shop_components/content.ex +++ b/lib/berrypod_web/components/shop_components/content.ex @@ -132,21 +132,72 @@ defmodule BerrypodWeb.ShopComponents.Content do @doc """ Renders the order tracking card. + Submits `lookup_orders` to the parent LiveView. The `:sent` state shows a + confirmation message; `:not_found` shows the form again with an error note. + + ## Attributes + + * `tracking_state` - Optional. `:idle | :sent | :not_found`. Defaults to `:idle`. + ## Examples <.order_tracking_card /> + <.order_tracking_card tracking_state={@tracking_state} /> """ + attr :tracking_state, :atom, default: :idle + + def order_tracking_card(%{tracking_state: :sent} = assigns) do + ~H""" + <.shop_card class="card-section"> +

Check your inbox

+

+ We've sent a link to your email address. It'll expire after an hour. +

+ + + """ + end + + def order_tracking_card(%{tracking_state: :not_found} = assigns) do + ~H""" + <.shop_card class="card-section"> +

Track your order

+

+ No orders found for that address. Make sure you use the same email you checked out with. +

+
+ <.shop_input + type="email" + name="email" + placeholder="your@email.com" + class="email-input" + required + /> + <.shop_button type="submit">Try again +
+ + """ + end + def order_tracking_card(assigns) do ~H""" <.shop_card class="card-section">

Track your order

- Enter your email and I'll send you a link to check your order status. + Enter the email address you used at checkout and we'll send you a link.

-
- <.shop_input type="email" placeholder="your@email.com" class="email-input" /> - <.shop_button>Send -
+
+ <.shop_input + type="email" + name="email" + placeholder="your@email.com" + class="email-input" + required + /> + <.shop_button type="submit">Send link +
""" end diff --git a/lib/berrypod_web/controllers/order_lookup_controller.ex b/lib/berrypod_web/controllers/order_lookup_controller.ex new file mode 100644 index 0000000..cc82c0d --- /dev/null +++ b/lib/berrypod_web/controllers/order_lookup_controller.ex @@ -0,0 +1,32 @@ +defmodule BerrypodWeb.OrderLookupController do + use BerrypodWeb, :controller + + @salt "order_lookup" + @max_age 3_600 + + def verify(conn, %{"token" => token}) do + case Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do + {:ok, email} -> + conn + |> put_session(:order_lookup_email, email) + |> redirect(to: ~p"/orders") + + {:error, :expired} -> + conn + |> put_flash(:error, "That link has expired. Please request a new one.") + |> redirect(to: ~p"/contact") + + {:error, _} -> + conn + |> put_flash(:error, "That link is invalid.") + |> redirect(to: ~p"/contact") + end + end + + @doc """ + Generates a signed, time-limited token for the given email address. + """ + def generate_token(email) do + Phoenix.Token.sign(BerrypodWeb.Endpoint, @salt, email) + end +end diff --git a/lib/berrypod_web/live/shop/contact.ex b/lib/berrypod_web/live/shop/contact.ex index 1a75f80..d6a1fd1 100644 --- a/lib/berrypod_web/live/shop/contact.ex +++ b/lib/berrypod_web/live/shop/contact.ex @@ -1,6 +1,10 @@ defmodule BerrypodWeb.Shop.Contact do use BerrypodWeb, :live_view + alias Berrypod.Orders + alias Berrypod.Orders.OrderNotifier + alias BerrypodWeb.OrderLookupController + @impl true def mount(_params, _session, socket) do {:ok, @@ -10,7 +14,30 @@ defmodule BerrypodWeb.Shop.Contact do :page_description, "Get in touch with us for any questions or help with your order." ) - |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")} + |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact") + |> assign(:tracking_state, :idle)} + end + + @impl true + def handle_event("lookup_orders", %{"email" => email}, socket) do + orders = Orders.list_orders_by_email(email) + + state = + if orders == [] do + :not_found + else + token = OrderLookupController.generate_token(email) + link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}" + OrderNotifier.deliver_order_lookup(email, link) + :sent + end + + {:noreply, assign(socket, :tracking_state, state)} + end + + @impl true + def handle_event("reset_tracking", _params, socket) do + {:noreply, assign(socket, :tracking_state, :idle)} end @impl true diff --git a/lib/berrypod_web/live/shop/order_detail.ex b/lib/berrypod_web/live/shop/order_detail.ex new file mode 100644 index 0000000..d530e04 --- /dev/null +++ b/lib/berrypod_web/live/shop/order_detail.ex @@ -0,0 +1,55 @@ +defmodule BerrypodWeb.Shop.OrderDetail do + use BerrypodWeb, :live_view + + alias Berrypod.Orders + alias Berrypod.Products + alias Berrypod.Products.ProductImage + + @impl true + def mount(_params, session, socket) do + {:ok, assign(socket, :lookup_email, session["order_lookup_email"])} + end + + @impl true + def handle_params(%{"order_number" => order_number}, _uri, socket) do + email = socket.assigns.lookup_email + + order = Orders.get_order_by_number(order_number) + + normalised = fn e -> String.downcase(String.trim(e || "")) end + + if order && order.payment_status == "paid" && + email && normalised.(order.customer_email) == normalised.(email) do + variant_ids = Enum.map(order.items, & &1.variant_id) + variants = Products.get_variants_with_products(variant_ids) + + thumbnails = + Map.new(variants, fn {id, variant} -> + thumb = + case variant.product.images do + [first | _] -> ProductImage.thumbnail_url(first) + _ -> nil + end + + slug = if variant.product.visible, do: variant.product.slug, else: nil + + {id, %{thumb: thumb, slug: slug}} + end) + + {:noreply, + socket + |> assign(:page_title, "Order #{order_number}") + |> assign(:order, order) + |> assign(:thumbnails, thumbnails)} + else + {:noreply, push_navigate(socket, to: ~p"/orders")} + end + end + + @impl true + def render(assigns) do + ~H""" + + """ + end +end diff --git a/lib/berrypod_web/live/shop/orders.ex b/lib/berrypod_web/live/shop/orders.ex new file mode 100644 index 0000000..aff0116 --- /dev/null +++ b/lib/berrypod_web/live/shop/orders.ex @@ -0,0 +1,34 @@ +defmodule BerrypodWeb.Shop.Orders do + use BerrypodWeb, :live_view + + alias Berrypod.Orders + + @impl true + def mount(_params, session, socket) do + email = session["order_lookup_email"] + + socket = + socket + |> assign(:page_title, "Your orders") + |> assign(:lookup_email, email) + + socket = + if email do + assign(socket, :orders, Orders.list_orders_by_email(email)) + else + assign(socket, :orders, nil) + end + + {:ok, socket} + end + + @impl true + def handle_params(_params, _uri, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" + + """ + end +end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 4490eb5..624ea4e 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -82,6 +82,8 @@ defmodule BerrypodWeb.Router do live "/products/:id", Shop.ProductShow, :show live "/cart", Shop.Cart, :index live "/checkout/success", Shop.CheckoutSuccess, :show + live "/orders", Shop.Orders, :index + live "/orders/:order_number", Shop.OrderDetail, :show end # Checkout (POST — creates Stripe session and redirects) @@ -157,6 +159,13 @@ defmodule BerrypodWeb.Router do end end + # Order lookup verification — sets session email then redirects to /orders + scope "/", BerrypodWeb do + pipe_through [:browser] + + get "/orders/verify/:token", OrderLookupController, :verify + end + # Setup page — minimal live_session, no theme/cart/search hooks scope "/", BerrypodWeb do pipe_through [:browser] diff --git a/test/berrypod/orders/order_notifier_test.exs b/test/berrypod/orders/order_notifier_test.exs index 5ee61de..dc71fc3 100644 --- a/test/berrypod/orders/order_notifier_test.exs +++ b/test/berrypod/orders/order_notifier_test.exs @@ -93,6 +93,20 @@ defmodule Berrypod.Orders.OrderNotifierTest do end end + describe "deliver_order_lookup/2" do + test "sends magic link email" do + link = "https://example.com/orders/verify/tok" + assert {:ok, _email} = OrderNotifier.deliver_order_lookup("buyer@example.com", link) + + assert_email_sent(fn email -> + assert email.to == [{"", "buyer@example.com"}] + assert email.subject == "Your order lookup link" + assert email.text_body =~ link + assert email.text_body =~ "expires in 1 hour" + end) + end + end + describe "deliver_shipping_notification/1" do test "sends notification with tracking info" do {order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"}) diff --git a/test/berrypod/orders_test.exs b/test/berrypod/orders_test.exs index 94ddf11..618434c 100644 --- a/test/berrypod/orders_test.exs +++ b/test/berrypod/orders_test.exs @@ -1,6 +1,7 @@ defmodule Berrypod.OrdersTest do use Berrypod.DataCase, async: false + import Ecto.Query import Mox import Berrypod.OrdersFixtures @@ -216,4 +217,62 @@ defmodule Berrypod.OrdersTest do assert is_nil(Orders.get_order_by_number("SS-000000-XXXX")) end end + + describe "list_orders_by_email/1" do + test "returns paid orders for the given email" do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + results = Orders.list_orders_by_email("buyer@example.com") + ids = Enum.map(results, & &1.id) + + assert order.id in ids + end + + test "excludes pending and failed orders" do + order_fixture(%{customer_email: "buyer@example.com"}) + order_fixture(%{customer_email: "buyer@example.com", payment_status: "failed"}) + + assert Orders.list_orders_by_email("buyer@example.com") == [] + end + + test "excludes other customers' orders" do + order_fixture(%{customer_email: "other@example.com", payment_status: "paid"}) + + assert Orders.list_orders_by_email("buyer@example.com") == [] + end + + test "is case-insensitive" do + order = order_fixture(%{customer_email: "Buyer@Example.COM", payment_status: "paid"}) + + results = Orders.list_orders_by_email("buyer@example.com") + ids = Enum.map(results, & &1.id) + + assert order.id in ids + end + + test "preloads items" do + order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + [order] = Orders.list_orders_by_email("buyer@example.com") + assert Ecto.assoc_loaded?(order.items) + end + + test "returns newest first" do + old = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + new = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + # Force different inserted_at by updating order records + now = DateTime.utc_now() |> DateTime.truncate(:second) + earlier = DateTime.add(now, -60, :second) + + Berrypod.Repo.update_all( + from(o in Berrypod.Orders.Order, where: o.id == ^old.id), + set: [inserted_at: earlier] + ) + + [first, second] = Orders.list_orders_by_email("buyer@example.com") + assert first.id == new.id + assert second.id == old.id + end + end end diff --git a/test/berrypod_web/live/shop/contact_test.exs b/test/berrypod_web/live/shop/contact_test.exs new file mode 100644 index 0000000..f2f4dd4 --- /dev/null +++ b/test/berrypod_web/live/shop/contact_test.exs @@ -0,0 +1,97 @@ +defmodule BerrypodWeb.Shop.ContactTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Swoosh.TestAssertions + import Berrypod.AccountsFixtures + import Berrypod.OrdersFixtures + + setup do + user_fixture() + {:ok, _} = Berrypod.Settings.set_site_live(true) + # drain the confirmation email user_fixture sends so it doesn't leak into assertions + receive do + {:email, _} -> :ok + after + 0 -> :ok + end + + :ok + end + + describe "contact page" do + test "renders the contact page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/contact") + + assert html =~ "Get in touch" + assert html =~ "Track your order" + end + + test "shows order lookup form by default", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/contact") + + assert html =~ "Enter the email address you used at checkout" + assert html =~ "Send link" + end + end + + describe "order lookup" do + test "sends magic link when orders exist for email", %{conn: conn} do + order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, view, _html} = live(conn, ~p"/contact") + + html = render_submit(view, "lookup_orders", %{"email" => "buyer@example.com"}) + + assert html =~ "Check your inbox" + assert html =~ "sent a link to your email address" + assert_email_sent(to: "buyer@example.com", subject: "Your order lookup link") + end + + test "shows not-found state for unknown email", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/contact") + + html = render_submit(view, "lookup_orders", %{"email" => "nobody@example.com"}) + + assert html =~ "No orders found for that address" + assert html =~ "Try again" + assert_no_email_sent() + end + + test "is case-insensitive for email lookup", %{conn: conn} do + order_fixture(%{customer_email: "Buyer@Example.com", payment_status: "paid"}) + + {:ok, view, _html} = live(conn, ~p"/contact") + + html = render_submit(view, "lookup_orders", %{"email" => "buyer@example.com"}) + + assert html =~ "Check your inbox" + # link is sent to the typed address, not the stored one + assert_email_sent(to: "buyer@example.com") + end + + test "ignores pending/failed orders", %{conn: conn} do + order_fixture(%{customer_email: "buyer@example.com"}) + order_fixture(%{customer_email: "buyer@example.com", payment_status: "failed"}) + + {:ok, view, _html} = live(conn, ~p"/contact") + + html = render_submit(view, "lookup_orders", %{"email" => "buyer@example.com"}) + + assert html =~ "No orders found for that address" + assert_no_email_sent() + end + + test "reset button returns to idle form", %{conn: conn} do + order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, view, _html} = live(conn, ~p"/contact") + render_submit(view, "lookup_orders", %{"email" => "buyer@example.com"}) + + html = render_click(view, "reset_tracking") + + assert html =~ "Enter the email address you used at checkout" + assert html =~ "Send link" + end + end +end diff --git a/test/berrypod_web/live/shop/orders_test.exs b/test/berrypod_web/live/shop/orders_test.exs new file mode 100644 index 0000000..b041df2 --- /dev/null +++ b/test/berrypod_web/live/shop/orders_test.exs @@ -0,0 +1,145 @@ +defmodule BerrypodWeb.Shop.OrdersTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + import Berrypod.OrdersFixtures + + setup do + user_fixture() + {:ok, _} = Berrypod.Settings.set_site_live(true) + :ok + end + + defp with_lookup_email(conn, email) do + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session("order_lookup_email", email) + end + + describe "orders list — no session email" do + test "shows expired link message", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/orders") + + assert html =~ "expired or is invalid" + end + end + + describe "orders list — with session email" do + setup %{conn: conn} do + %{conn: with_lookup_email(conn, "buyer@example.com")} + end + + test "shows email address in header", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/orders") + + assert html =~ "buyer@example.com" + end + + test "shows empty state when no paid orders", %{conn: conn} do + order_fixture(%{customer_email: "buyer@example.com"}) + + {:ok, _view, html} = live(conn, ~p"/orders") + + assert html =~ "No orders found for that email address" + end + + test "lists paid orders for the session email", %{conn: conn} do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, _view, html} = live(conn, ~p"/orders") + + assert html =~ order.order_number + assert html =~ "Test product" + end + + test "does not show other customers' orders", %{conn: conn} do + other = order_fixture(%{customer_email: "other@example.com", payment_status: "paid"}) + + {:ok, _view, html} = live(conn, ~p"/orders") + + refute html =~ other.order_number + end + + test "links to order detail page", %{conn: conn} do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, _view, html} = live(conn, ~p"/orders") + + assert html =~ ~p"/orders/#{order.order_number}" + end + end + + describe "order detail" do + setup %{conn: conn} do + %{conn: with_lookup_email(conn, "buyer@example.com")} + end + + test "renders order detail for matching email", %{conn: conn} do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, _view, html} = live(conn, ~p"/orders/#{order.order_number}") + + assert html =~ order.order_number + assert html =~ "Test product" + end + + test "redirects if order belongs to different email", %{conn: conn} do + order = order_fixture(%{customer_email: "other@example.com", payment_status: "paid"}) + + {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/orders/#{order.order_number}") + + assert to == ~p"/orders" + end + + test "redirects if order is not paid", %{conn: conn} do + order = order_fixture(%{customer_email: "buyer@example.com"}) + + {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/orders/#{order.order_number}") + + assert to == ~p"/orders" + end + + test "redirects if order number is unknown", %{conn: conn} do + {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/orders/SS-UNKNOWN-0000") + + assert to == ~p"/orders" + end + + test "shows tracking card when tracking number present", %{conn: conn} do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + {:ok, order} = + Berrypod.Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + tracking_number: "RM123456789GB" + }) + + {:ok, _view, html} = live(conn, ~p"/orders/#{order.order_number}") + + assert html =~ "Shipment tracking" + assert html =~ "RM123456789GB" + end + + test "shows shipping address when present", %{conn: conn} do + order = + order_fixture(%{ + customer_email: "buyer@example.com", + payment_status: "paid", + shipping_address: %{ + "name" => "Jane Doe", + "line1" => "42 Test Street", + "city" => "London", + "postal_code" => "SW1A 1AA", + "country" => "GB" + } + }) + + {:ok, _view, html} = live(conn, ~p"/orders/#{order.order_number}") + + assert html =~ "Jane Doe" + assert html =~ "42 Test Street" + assert html =~ "London" + end + end +end