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" />