add pagination across all admin and shop views
All checks were successful
deploy / deploy (push) Successful in 1m38s

URL-based offset pagination with ?page=N for bookmarkable pages.
Admin views use push_patch, shop collection uses navigate links.
Responsive on mobile with horizontal-scroll tables and stacking
pagination controls. Includes dev seed script for testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-01 09:42:34 +00:00
parent 7f6fd012a5
commit 3480b326a9
21 changed files with 1485 additions and 211 deletions

View File

@@ -213,6 +213,20 @@ defmodule Berrypod.Media do
|> Repo.all()
end
@doc """
Like `list_images/1` but returns a `%Pagination{}` struct.
"""
def list_images_paginated(opts \\ []) do
from(i in ImageSchema,
select: %{i | data: nil},
order_by: [desc: i.inserted_at]
)
|> filter_by_type(opts[:type])
|> filter_by_search(opts[:search])
|> filter_by_tag(opts[:tag])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 36)
end
defp filter_by_type(query, nil), do: query
defp filter_by_type(query, ""), do: query
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)

View File

@@ -162,6 +162,29 @@ defmodule Berrypod.Newsletter do
Repo.all(query)
end
@doc """
Like `list_subscribers/1` but returns a `%Pagination{}` struct.
"""
def list_subscribers_paginated(opts \\ []) do
query = from(s in Subscriber, order_by: [desc: s.inserted_at])
query =
case Keyword.get(opts, :status) do
nil -> query
"all" -> query
status -> from(s in query, where: s.status == ^status)
end
query =
case Keyword.get(opts, :search) do
nil -> query
"" -> query
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
end
Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc "Returns subscriber counts grouped by status."
def count_subscribers_by_status do
from(s in Subscriber,
@@ -228,6 +251,11 @@ defmodule Berrypod.Newsletter do
|> Repo.all()
end
def list_campaigns_paginated(opts \\ []) do
from(c in Campaign, order_by: [desc: c.inserted_at])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
def get_campaign!(id), do: Repo.get!(Campaign, id)
def create_campaign(attrs) do

View File

@@ -146,6 +146,7 @@ defmodule Berrypod.Newsletter.Notifier do
@doc false
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
logo_html = build_logo_html(shop_name)
footer =
if unsubscribe_url do
"""
@@ -244,7 +245,10 @@ defmodule Berrypod.Newsletter.Notifier do
defp has_favicon_icon? do
require Ecto.Query
Berrypod.Repo.exists?(Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon"))
Berrypod.Repo.exists?(
Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon")
)
end
# Turns bare URLs in escaped text into clickable links.

View File

@@ -35,6 +35,17 @@ defmodule Berrypod.Orders do
|> Repo.all()
end
@doc """
Like `list_orders/1` but returns a `%Pagination{}` struct.
"""
def list_orders_paginated(opts \\ []) do
Order
|> maybe_filter_status(opts[:status])
|> order_by([o], desc: o.inserted_at)
|> preload(:items)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
defp maybe_filter_status(query, nil), do: query
defp maybe_filter_status(query, "all"), do: query
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)

119
lib/berrypod/pagination.ex Normal file
View File

@@ -0,0 +1,119 @@
defmodule Berrypod.Pagination do
@moduledoc """
Offset-based pagination for Ecto queries.
Returns a `%Berrypod.Pagination{}` struct with items, page metadata,
and total count. Context modules pipe their filtered/ordered queries
into `paginate/2` instead of `Repo.all/0`.
"""
import Ecto.Query
alias Berrypod.Repo
@enforce_keys [:items, :page, :per_page, :total_count, :total_pages]
defstruct [:items, :page, :per_page, :total_count, :total_pages]
@type t :: %__MODULE__{
items: [any()],
page: pos_integer(),
per_page: pos_integer(),
total_count: non_neg_integer(),
total_pages: non_neg_integer()
}
@doc """
Paginates an Ecto query.
Options:
* `:page` - current page (default 1, clamped to valid range)
* `:per_page` - items per page (default 25)
"""
def paginate(query, opts \\ []) do
page = max(opts[:page] || 1, 1)
per_page = opts[:per_page] || 25
count_query =
query
|> exclude(:preload)
|> exclude(:select)
|> exclude(:order_by)
total_count = Repo.aggregate(count_query, :count)
total_pages = max(ceil(total_count / per_page), 1)
page = min(page, total_pages)
items =
query
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
%__MODULE__{
items: items,
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
}
end
@doc """
Returns "Showing X\u2013Y of Z" for display, or "No results" when empty.
"""
def showing_text(%__MODULE__{total_count: 0}), do: "No results"
def showing_text(%__MODULE__{} = p) do
first = (p.page - 1) * p.per_page + 1
last = min(p.page * p.per_page, p.total_count)
"Showing #{first}\u2013#{last} of #{p.total_count}"
end
@doc """
Computes page numbers with `:ellipsis` gaps for display.
Always shows first, last, and a window around the current page.
Returns a flat list like `[1, :ellipsis, 4, 5, 6, :ellipsis, 20]`.
"""
def page_numbers(%__MODULE__{total_pages: total}) when total <= 7 do
Enum.to_list(1..total)
end
def page_numbers(%__MODULE__{page: current, total_pages: total}) do
window =
[current - 1, current, current + 1]
|> Enum.filter(&(&1 >= 1 and &1 <= total))
pages = Enum.uniq([1] ++ window ++ [total]) |> Enum.sort()
# Insert :ellipsis between non-consecutive numbers
pages
|> Enum.chunk_every(2, 1, :discard)
|> Enum.flat_map(fn [a, b] ->
if b - a > 1, do: [a, :ellipsis], else: [a]
end)
|> Kernel.++([List.last(pages)])
end
@doc """
Parses a page number from request params. Always returns >= 1.
"""
def parse_page(params, key \\ "page") do
case params[key] do
nil ->
1
val when is_binary(val) ->
case Integer.parse(val) do
{n, _} when n > 0 -> n
_ -> 1
end
val when is_integer(val) and val > 0 ->
val
_ ->
1
end
end
end

View File

@@ -171,6 +171,21 @@ defmodule Berrypod.Products do
|> Repo.all()
end
@doc """
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
Accepts the same filter/sort options plus `:page` and `:per_page`.
"""
def list_visible_products_paginated(opts \\ []) do
Product
|> where([p], p.visible == true and p.status == "active")
|> apply_visible_filters(opts)
|> apply_sort(opts[:sort])
|> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
end
@doc """
Lists distinct categories from visible, active products.
Returns a list of `%{name, slug, image_url}` where `image_url` is the
@@ -308,6 +323,20 @@ defmodule Berrypod.Products do
|> Repo.all()
end
@doc """
Like `list_products_admin/1` but returns a `%Pagination{}` struct.
Accepts the same filter options plus `:page` and `:per_page`.
"""
def list_products_admin_paginated(opts \\ []) do
Product
|> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Returns distinct category names from all products (including hidden/draft).
"""

View File

@@ -217,6 +217,11 @@ defmodule Berrypod.Redirects do
|> Repo.all()
end
def list_redirects_paginated(opts \\ []) do
from(r in Redirect, order_by: [desc: r.inserted_at])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Gets a single redirect by ID.
"""
@@ -275,6 +280,16 @@ defmodule Berrypod.Redirects do
|> Repo.all()
end
def list_broken_urls_paginated(opts \\ []) do
status = opts[:status] || "pending"
from(b in BrokenUrl,
where: b.status == ^status,
order_by: [desc: b.prior_analytics_hits, desc: b.recent_404_count]
)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Resolves a broken URL by creating a redirect and updating the record.
"""

View File

@@ -333,34 +333,36 @@ defmodule BerrypodWeb.CoreComponents do
end
~H"""
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
<div class="admin-table-wrap">
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@@ -622,4 +624,88 @@ defmodule BerrypodWeb.CoreComponents do
|> JS.exec("close()", to: "##{id}")
|> JS.pop_focus()
end
# ── Pagination ───────────────────────────────────────────────────
@doc """
Renders pagination controls for admin lists.
Hidden when there's only one page. Shows "Showing X-Y of Z" on the left,
page number buttons with ellipsis on the right.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `patch` - Required. Base URL path for pagination links (e.g. "/admin/products").
* `params` - Extra query params to preserve (e.g. %{"tab" => "broken"}). Default `%{}`.
"""
attr :page, Berrypod.Pagination, required: true
attr :patch, :string, required: true
attr :params, :map, default: %{}
def admin_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="admin-pagination"
>
<p class="admin-pagination-showing">{@showing}</p>
<div class="admin-pagination-buttons">
<.link
patch={admin_page_url(@patch, @page.page - 1, @params)}
class={["admin-btn admin-btn-sm admin-btn-ghost", @page.page == 1 && "admin-btn-disabled"]}
aria-label="Previous page"
aria-disabled={@page.page == 1 && "true"}
tabindex={@page.page == 1 && "-1"}
>
<.icon name="hero-chevron-left" class="size-4" />
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="admin-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
patch={admin_page_url(@patch, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={[
"admin-btn admin-btn-sm",
if(n == @page.page, do: "admin-btn-primary", else: "admin-btn-ghost")
]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
patch={admin_page_url(@patch, @page.page + 1, @params)}
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@page.page == @page.total_pages && "admin-btn-disabled"
]}
aria-label="Next page"
aria-disabled={@page.page == @page.total_pages && "true"}
tabindex={@page.page == @page.total_pages && "-1"}
>
<.icon name="hero-chevron-right" class="size-4" />
</.link>
</div>
</nav>
"""
end
defp admin_page_url(base, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{}, do: base, else: base <> "?" <> URI.encode_query(query)
end
end

View File

@@ -1714,4 +1714,84 @@ defmodule BerrypodWeb.ShopComponents.Product do
</div>
"""
end
# ── Shop pagination ────────────────────────────────────────────
@doc """
Renders pagination controls for the shop collection page.
Uses URL navigation (`<.link navigate=...>`) so pages are bookmarkable.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `base_path` - Required. The base URL path (e.g. "/collections/all").
* `params` - Extra query params to preserve (e.g. %{"sort" => "newest"}).
"""
attr :page, Berrypod.Pagination, required: true
attr :base_path, :string, required: true
attr :params, :map, default: %{}
def shop_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="shop-pagination"
>
<p class="shop-pagination-showing">{@showing}</p>
<div class="shop-pagination-buttons">
<.link
:if={@page.page > 1}
navigate={pagination_url(@base_path, @page.page - 1, @params)}
class="shop-pagination-btn"
aria-label="Previous page"
>
&lsaquo; Prev
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="shop-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
navigate={pagination_url(@base_path, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
:if={@page.page < @page.total_pages}
navigate={pagination_url(@base_path, @page.page + 1, @params)}
class="shop-pagination-btn"
aria-label="Next page"
>
Next &rsaquo;
</.link>
</div>
</nav>
"""
end
defp pagination_url(base_path, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{} do
base_path
else
base_path <> "?" <> URI.encode_query(query)
end
end
end

View File

@@ -5,20 +5,18 @@ defmodule BerrypodWeb.Admin.Media do
@impl true
def mount(_params, _session, socket) do
images = Media.list_images()
socket =
socket
|> assign(:page_title, "Media")
|> assign(:filter_type, nil)
|> assign(:filter_search, "")
|> assign(:filter_orphans, false)
|> assign(:pagination, nil)
|> assign(:selected_image, nil)
|> assign(:selected_usages, [])
|> assign(:edit_form, nil)
|> assign(:upload_alt, "")
|> assign(:confirm_delete, false)
|> stream(:images, images)
|> allow_upload(:media_upload,
accept: ~w(.png .jpg .jpeg .webp .svg .gif),
max_entries: 1,
@@ -30,6 +28,20 @@ defmodule BerrypodWeb.Admin.Media do
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
page_num = Berrypod.Pagination.parse_page(params)
opts = image_filter_opts(socket)
page = Media.list_images_paginated([page: page_num] ++ opts)
socket =
socket
|> assign(:pagination, page)
|> stream(:images, page.items, reset: true)
{:noreply, socket}
end
defp handle_progress(:media_upload, entry, socket) do
if entry.done? do
alt = socket.assigns.upload_alt
@@ -60,15 +72,25 @@ defmodule BerrypodWeb.Admin.Media do
@impl true
def handle_event("filter_type", %{"type" => type}, socket) do
type = if type == "", do: nil, else: type
{:noreply, reload_images(assign(socket, :filter_type, type))}
{:noreply,
socket
|> assign(:filter_type, type)
|> reload_images()}
end
def handle_event("filter_search", %{"value" => value}, socket) do
{:noreply, reload_images(assign(socket, :filter_search, value))}
{:noreply,
socket
|> assign(:filter_search, value)
|> reload_images()}
end
def handle_event("toggle_orphans", _params, socket) do
{:noreply, reload_images(assign(socket, :filter_orphans, !socket.assigns.filter_orphans))}
{:noreply,
socket
|> assign(:filter_orphans, !socket.assigns.filter_orphans)
|> reload_images()}
end
def handle_event("select_image", %{"id" => id}, socket) do
@@ -159,26 +181,29 @@ defmodule BerrypodWeb.Admin.Media do
# ── Private helpers ──────────────────────────────────────────────
defp image_filter_opts(socket) do
[
type: socket.assigns.filter_type,
search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search),
tag: nil
]
|> Enum.reject(fn {_, v} -> is_nil(v) end)
end
defp reload_images(socket) do
opts =
[
type: socket.assigns.filter_type,
search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search),
tag: nil
]
|> Enum.reject(fn {_, v} -> is_nil(v) end)
if socket.assigns.filter_orphans do
# Orphan mode: load all, filter in Elixir (no pagination)
opts = image_filter_opts(socket)
images = Media.list_images(opts)
used = Media.used_image_ids()
orphans = Enum.reject(images, &MapSet.member?(used, &1.id))
images = Media.list_images(opts)
images =
if socket.assigns.filter_orphans do
used = Media.used_image_ids()
Enum.reject(images, &MapSet.member?(used, &1.id))
else
images
end
stream(socket, :images, images, reset: true)
socket
|> assign(:pagination, nil)
|> stream(:images, orphans, reset: true)
else
push_patch(socket, to: ~p"/admin/media")
end
end
defp format_file_size(nil), do: ""
@@ -284,49 +309,53 @@ defmodule BerrypodWeb.Admin.Media do
</div>
<div class="media-main">
<%!-- image grid --%>
<div id="media-grid" phx-update="stream" class="media-grid">
<div
:for={{dom_id, image} <- @streams.images}
id={dom_id}
phx-click="select_image"
phx-value-id={image.id}
class={[
"media-card",
@selected_image && @selected_image.id == image.id && "media-card-selected"
]}
>
<div class="media-card-thumb">
<%= if image.is_svg do %>
<div class="media-card-svg-placeholder">
<.icon name="hero-code-bracket" class="size-8" />
<span>SVG</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(image) do %>
<img src={thumb} alt={image.alt || image.filename} loading="lazy" />
<% else %>
<%!-- image grid + pagination --%>
<div class="media-grid-wrapper">
<div id="media-grid" phx-update="stream" class="media-grid">
<div
:for={{dom_id, image} <- @streams.images}
id={dom_id}
phx-click="select_image"
phx-value-id={image.id}
class={[
"media-card",
@selected_image && @selected_image.id == image.id && "media-card-selected"
]}
>
<div class="media-card-thumb">
<%= if image.is_svg do %>
<div class="media-card-svg-placeholder">
<.icon name="hero-photo" class="size-8" />
<.icon name="hero-code-bracket" class="size-8" />
<span>SVG</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(image) do %>
<img src={thumb} alt={image.alt || image.filename} loading="lazy" />
<% else %>
<div class="media-card-svg-placeholder">
<.icon name="hero-photo" class="size-8" />
</div>
<% end %>
<% end %>
<% end %>
</div>
<div class="media-card-info">
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
class="media-card-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
</span>
<div class="media-card-info">
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
class="media-card-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
</span>
</div>
</div>
</div>
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
</div>
<%!-- detail panel --%>

View File

@@ -6,8 +6,8 @@ defmodule BerrypodWeb.Admin.Newsletter do
@impl true
def mount(_params, _session, socket) do
counts = Newsletter.count_subscribers_by_status()
subscribers = Newsletter.list_subscribers()
campaigns = Newsletter.list_campaigns()
sub_page = Newsletter.list_subscribers_paginated(page: 1)
camp_page = Newsletter.list_campaigns_paginated(page: 1)
{:ok,
socket
@@ -15,38 +15,47 @@ defmodule BerrypodWeb.Admin.Newsletter do
|> assign(:tab, "overview")
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|> assign(:status_counts, counts)
|> assign(:subscriber_count, length(subscribers))
|> assign(:campaign_count, length(campaigns))
|> assign(:subscriber_pagination, sub_page)
|> assign(:subscriber_count, sub_page.total_count)
|> assign(:campaign_pagination, camp_page)
|> assign(:campaign_count, camp_page.total_count)
|> assign(:status_filter, "all")
|> assign(:search, "")
|> stream(:subscribers, subscribers)
|> stream(:campaigns, campaigns)}
|> stream(:subscribers, sub_page.items)
|> stream(:campaigns, camp_page.items)}
end
@impl true
def handle_params(%{"tab" => tab}, _uri, socket)
when tab in ~w(overview subscribers campaigns) do
def handle_params(params, _uri, socket) do
tab =
if params["tab"] in ~w(overview subscribers campaigns), do: params["tab"], else: "overview"
page_num = Berrypod.Pagination.parse_page(params)
socket = assign(socket, :tab, tab)
socket =
case tab do
"subscribers" ->
subscribers =
Newsletter.list_subscribers(
page =
Newsletter.list_subscribers_paginated(
status: socket.assigns.status_filter,
search: socket.assigns.search
search: socket.assigns.search,
page: page_num
)
socket
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)
|> assign(:subscriber_pagination, page)
|> assign(:subscriber_count, page.total_count)
|> stream(:subscribers, page.items, reset: true)
"campaigns" ->
campaigns = Newsletter.list_campaigns()
page = Newsletter.list_campaigns_paginated(page: page_num)
socket
|> assign(:campaign_count, length(campaigns))
|> stream(:campaigns, campaigns, reset: true)
|> assign(:campaign_pagination, page)
|> assign(:campaign_count, page.total_count)
|> stream(:campaigns, page.items, reset: true)
_ ->
socket
@@ -55,8 +64,6 @@ defmodule BerrypodWeb.Admin.Newsletter do
{:noreply, socket}
end
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_event("toggle_enabled", _params, socket) do
new_value = !socket.assigns.newsletter_enabled
@@ -71,23 +78,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
end
def handle_event("filter_subscribers", %{"status" => status}, socket) do
subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search)
{:noreply,
socket
|> assign(:status_filter, status)
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)}
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
end
def handle_event("search_subscribers", %{"search" => term}, socket) do
subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term)
{:noreply,
socket
|> assign(:search, term)
|> assign(:subscriber_count, length(subscribers))
|> stream(:subscribers, subscribers, reset: true)}
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
end
def handle_event("delete_subscriber", %{"id" => id}, socket) do
@@ -152,12 +153,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
status_filter={@status_filter}
status_counts={@status_counts}
subscriber_count={@subscriber_count}
subscriber_pagination={@subscriber_pagination}
search={@search}
/>
</div>
<div :if={@tab == "campaigns"}>
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} />
<.campaigns_tab
streams={@streams}
campaign_count={@campaign_count}
campaign_pagination={@campaign_pagination}
/>
</div>
"""
end
@@ -254,6 +260,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
attr :status_filter, :string, required: true
attr :status_counts, :map, required: true
attr :subscriber_count, :integer, required: true
attr :subscriber_pagination, Berrypod.Pagination, required: true
attr :search, :string, required: true
defp subscribers_tab(assigns) do
@@ -323,6 +330,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
</:action>
</.table>
<.admin_pagination
:if={@subscriber_count > 0}
page={@subscriber_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "subscribers"}}
/>
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No subscribers yet</p>
@@ -336,6 +350,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
attr :streams, :any, required: true
attr :campaign_count, :integer, required: true
attr :campaign_pagination, Berrypod.Pagination, required: true
defp campaigns_tab(assigns) do
~H"""
@@ -370,6 +385,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
</:action>
</.table>
<.admin_pagination
:if={@campaign_count > 0}
page={@campaign_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "campaigns"}}
/>
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No campaigns yet</p>

View File

@@ -7,32 +7,38 @@ defmodule BerrypodWeb.Admin.Orders do
@impl true
def mount(_params, _session, socket) do
counts = Orders.count_orders_by_status()
orders = Orders.list_orders()
socket =
socket
|> assign(:page_title, "Orders")
|> assign(:status_filter, "all")
|> assign(:status_counts, counts)
|> assign(:order_count, length(orders))
|> stream(:orders, orders)
{:ok, socket}
end
@impl true
def handle_event("filter", %{"status" => status}, socket) do
orders = Orders.list_orders(status: status)
def handle_params(params, _uri, socket) do
page_num = Berrypod.Pagination.parse_page(params)
page = Orders.list_orders_paginated(status: socket.assigns.status_filter, page: page_num)
socket =
socket
|> assign(:status_filter, status)
|> assign(:order_count, length(orders))
|> stream(:orders, orders, reset: true)
|> assign(:pagination, page)
|> assign(:order_count, page.total_count)
|> stream(:orders, page.items, reset: true)
{:noreply, socket}
end
@impl true
def handle_event("filter", %{"status" => status}, socket) do
{:noreply,
socket
|> assign(:status_filter, status)
|> push_patch(to: ~p"/admin/orders")}
end
@impl true
def render(assigns) do
~H"""
@@ -90,6 +96,8 @@ defmodule BerrypodWeb.Admin.Orders do
</:col>
</.table>
<.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} />
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No orders yet</p>

View File

@@ -9,55 +9,58 @@ defmodule BerrypodWeb.Admin.Products do
def mount(_params, _session, socket) do
connections = Products.list_provider_connections()
categories = Products.list_all_categories()
products = Products.list_products_admin()
socket =
socket
|> assign(:page_title, "Products")
|> assign(:connections, connections)
|> assign(:categories, categories)
|> assign(:product_count, length(products))
|> assign(:provider_filter, "all")
|> assign(:category_filter, "all")
|> assign(:visibility_filter, "all")
|> assign(:stock_filter, "all")
|> assign(:sort, "newest")
|> stream(:products, products)
{:ok, socket}
end
@impl true
def handle_event("filter", params, socket) do
provider_filter = params["provider"] || socket.assigns.provider_filter
category_filter = params["category"] || socket.assigns.category_filter
visibility_filter = params["visibility"] || socket.assigns.visibility_filter
stock_filter = params["stock"] || socket.assigns.stock_filter
sort = params["sort"] || socket.assigns.sort
def handle_params(params, _uri, socket) do
page_num = Berrypod.Pagination.parse_page(params)
opts =
[]
|> maybe_add_filter(:provider_connection_id, provider_filter)
|> maybe_add_filter(:category, category_filter)
|> maybe_add_visibility(visibility_filter)
|> maybe_add_stock(stock_filter)
|> Keyword.put(:sort, sort)
build_filter_opts(
socket.assigns.provider_filter,
socket.assigns.category_filter,
socket.assigns.visibility_filter,
socket.assigns.stock_filter,
socket.assigns.sort
)
products = Products.list_products_admin(opts)
page = Products.list_products_admin_paginated([page: page_num] ++ opts)
socket =
socket
|> assign(:provider_filter, provider_filter)
|> assign(:category_filter, category_filter)
|> assign(:visibility_filter, visibility_filter)
|> assign(:stock_filter, stock_filter)
|> assign(:sort, sort)
|> assign(:product_count, length(products))
|> stream(:products, products, reset: true)
|> assign(:pagination, page)
|> assign(:product_count, page.total_count)
|> stream(:products, page.items, reset: true)
{:noreply, socket}
end
@impl true
def handle_event("filter", params, socket) do
socket =
socket
|> assign(:provider_filter, params["provider"] || socket.assigns.provider_filter)
|> assign(:category_filter, params["category"] || socket.assigns.category_filter)
|> assign(:visibility_filter, params["visibility"] || socket.assigns.visibility_filter)
|> assign(:stock_filter, params["stock"] || socket.assigns.stock_filter)
|> assign(:sort, params["sort"] || socket.assigns.sort)
{:noreply, push_patch(socket, to: ~p"/admin/products")}
end
@impl true
def handle_event("toggle_visibility", %{"id" => id}, socket) do
product =
@@ -177,6 +180,8 @@ defmodule BerrypodWeb.Admin.Products do
</:col>
</.table>
<.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} />
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No products yet</p>
@@ -272,6 +277,15 @@ defmodule BerrypodWeb.Admin.Products do
# Filter helpers
# ---------------------------------------------------------------------------
defp build_filter_opts(provider, category, visibility, stock, sort) do
[]
|> maybe_add_filter(:provider_connection_id, provider)
|> maybe_add_filter(:category, category)
|> maybe_add_visibility(visibility)
|> maybe_add_stock(stock)
|> Keyword.put(:sort, sort)
end
defp maybe_add_filter(opts, _key, "all"), do: opts
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)

View File

@@ -9,11 +9,16 @@ defmodule BerrypodWeb.Admin.Redirects do
def mount(_params, _session, socket) do
if connected?(socket), do: Redirects.subscribe()
redirect_page = Redirects.list_redirects_paginated(page: 1)
broken_page = Redirects.list_broken_urls_paginated(page: 1)
socket =
socket
|> assign(:page_title, "Redirects")
|> assign(:redirects, Redirects.list_redirects())
|> assign(:broken_urls, Redirects.list_broken_urls())
|> assign(:redirect_pagination, redirect_page)
|> assign(:broken_url_pagination, broken_page)
|> stream(:redirects, redirect_page.items)
|> stream(:broken_urls, broken_page.items)
|> assign(
:form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
@@ -25,16 +30,50 @@ defmodule BerrypodWeb.Admin.Redirects do
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects"
{:noreply, assign(socket, :tab, tab)}
page_num = Berrypod.Pagination.parse_page(params)
socket = assign(socket, :tab, tab)
socket =
case tab do
"redirects" ->
page = Redirects.list_redirects_paginated(page: page_num)
socket
|> assign(:redirect_pagination, page)
|> stream(:redirects, page.items, reset: true)
"broken" ->
page = Redirects.list_broken_urls_paginated(page: page_num)
socket
|> assign(:broken_url_pagination, page)
|> stream(:broken_urls, page.items, reset: true)
_ ->
socket
end
{:noreply, socket}
end
@impl true
def handle_info({:redirects_changed, _action}, socket) do
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
page = Redirects.list_redirects_paginated(page: socket.assigns.redirect_pagination.page)
{:noreply,
socket
|> assign(:redirect_pagination, page)
|> stream(:redirects, page.items, reset: true)}
end
def handle_info({:broken_urls_changed, _path}, socket) do
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
page = Redirects.list_broken_urls_paginated(page: socket.assigns.broken_url_pagination.page)
{:noreply,
socket
|> assign(:broken_url_pagination, page)
|> stream(:broken_urls, page.items, reset: true)}
end
@impl true
@@ -43,10 +82,15 @@ defmodule BerrypodWeb.Admin.Redirects do
end
def handle_event("delete_redirect", %{"id" => id}, socket) do
redirect = Redirects.get_redirect!(id)
{:ok, _} = Redirects.delete_redirect(redirect)
redirect_rec = Redirects.get_redirect!(id)
{:ok, _} = Redirects.delete_redirect(redirect_rec)
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
page = Redirects.list_redirects_paginated(page: socket.assigns.redirect_pagination.page)
{:noreply,
socket
|> assign(:redirect_pagination, page)
|> stream(:redirects, page.items, reset: true)}
end
def handle_event("create_redirect", %{"redirect" => params}, socket) do
@@ -80,7 +124,12 @@ defmodule BerrypodWeb.Admin.Redirects do
broken_url = Redirects.get_broken_url!(id)
{:ok, _} = Redirects.ignore_broken_url(broken_url)
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
page = Redirects.list_broken_urls_paginated(page: socket.assigns.broken_url_pagination.page)
{:noreply,
socket
|> assign(:broken_url_pagination, page)
|> stream(:broken_urls, page.items, reset: true)}
end
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
@@ -103,17 +152,27 @@ defmodule BerrypodWeb.Admin.Redirects do
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<.tab_button tab="redirects" label="Active" count={length(@redirects)} active={@tab} />
<.tab_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} />
<.tab_button
tab="redirects"
label="Active"
count={@redirect_pagination.total_count}
active={@tab}
/>
<.tab_button
tab="broken"
label="Broken URLs"
count={@broken_url_pagination.total_count}
active={@tab}
/>
<.tab_button tab="create" label="Create" active={@tab} />
</div>
<%= if @tab == "redirects" do %>
<.redirects_table redirects={@redirects} />
<.redirects_table streams={@streams} pagination={@redirect_pagination} />
<% end %>
<%= if @tab == "broken" do %>
<.broken_urls_table broken_urls={@broken_urls} />
<.broken_urls_table streams={@streams} pagination={@broken_url_pagination} />
<% end %>
<%= if @tab == "create" do %>
@@ -124,23 +183,23 @@ defmodule BerrypodWeb.Admin.Redirects do
defp redirects_table(assigns) do
~H"""
<%= if @redirects == [] do %>
<%= if @pagination.total_count == 0 do %>
<p>No redirects yet.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Source</th>
<th>Hits</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for redirect <- @redirects do %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Source</th>
<th>Hits</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody id="redirects-table" phx-update="stream">
<tr :for={{dom_id, redirect} <- @streams.redirects} id={dom_id}>
<td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td>
<td>
@@ -161,32 +220,38 @@ defmodule BerrypodWeb.Admin.Redirects do
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</tbody>
</table>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/redirects"}
params={%{"tab" => "redirects"}}
/>
<% end %>
"""
end
defp broken_urls_table(assigns) do
~H"""
<%= if @broken_urls == [] do %>
<%= if @pagination.total_count == 0 do %>
<p>No broken URLs detected.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>Path</th>
<th>Prior traffic</th>
<th>404s</th>
<th>First seen</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for broken_url <- @broken_urls do %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Path</th>
<th>Prior traffic</th>
<th>404s</th>
<th>First seen</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody id="broken-urls-table" phx-update="stream">
<tr :for={{dom_id, broken_url} <- @streams.broken_urls} id={dom_id}>
<td><code>{broken_url.path}</code></td>
<td>{broken_url.prior_analytics_hits}</td>
<td>{broken_url.recent_404_count}</td>
@@ -209,9 +274,15 @@ defmodule BerrypodWeb.Admin.Redirects do
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</tbody>
</table>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/redirects"}
params={%{"tab" => "broken"}}
/>
<% end %>
"""
end

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.Collection do
use BerrypodWeb, :live_view
alias Berrypod.{Pages, Products}
alias Berrypod.{Pages, Pagination, Products}
@sort_options [
{"featured", "Featured"},
@@ -28,18 +28,21 @@ defmodule BerrypodWeb.Shop.Collection do
@impl true
def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured"
page_num = Pagination.parse_page(params)
case load_collection(slug, sort) do
{:ok, title, category, products} ->
case load_collection(slug, sort, page_num) do
{:ok, title, category, pagination} ->
{:noreply,
socket
|> assign(:page_title, title)
|> assign(:page_description, collection_description(title))
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|> assign(:collection_title, title)
|> assign(:collection_slug, slug)
|> assign(:current_category, category)
|> assign(:current_sort, sort)
|> assign(:products, products)}
|> assign(:pagination, pagination)
|> assign(:products, pagination.items)}
:not_found ->
{:noreply,
@@ -49,22 +52,30 @@ defmodule BerrypodWeb.Shop.Collection do
end
end
defp load_collection("all", sort) do
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
defp load_collection("all", sort, page) do
pagination = Products.list_visible_products_paginated(sort: sort, page: page)
{:ok, "All Products", nil, pagination}
end
defp load_collection("sale", sort) do
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
defp load_collection("sale", sort, page) do
pagination = Products.list_visible_products_paginated(on_sale: true, sort: sort, page: page)
{:ok, "Sale", :sale, pagination}
end
defp load_collection(slug, sort) do
defp load_collection(slug, sort, page) do
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
nil ->
:not_found
category ->
products = Products.list_visible_products(category: category.name, sort: sort)
{:ok, category.name, category, products}
pagination =
Products.list_visible_products_paginated(
category: category.name,
sort: sort,
page: page
)
{:ok, category.name, category, pagination}
end
end

View File

@@ -439,10 +439,19 @@ defmodule BerrypodWeb.PageRenderer do
# ── Collection blocks ───────────────────────────────────────────
defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do
count =
if assigns[:pagination] do
assigns.pagination.total_count
else
length(assigns[:products] || [])
end
assigns = assign(assigns, :product_count, count)
~H"""
<.collection_header
title={assigns[:collection_title] || "All Products"}
product_count={length(assigns[:products] || [])}
product_count={@product_count}
/>
"""
end
@@ -525,7 +534,15 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
show_category = assigns[:current_category] in [nil, :sale]
assigns = assign(assigns, :show_category, show_category)
collection_slug = assigns[:collection_slug] || "all"
current_sort = assigns[:current_sort] || "featured"
sort_params = if current_sort != "featured", do: %{"sort" => current_sort}, else: %{}
assigns =
assigns
|> assign(:show_category, show_category)
|> assign(:collection_slug, collection_slug)
|> assign(:sort_params, sort_params)
~H"""
<div class="page-container">
@@ -541,6 +558,13 @@ defmodule BerrypodWeb.PageRenderer do
<% end %>
</.product_grid>
<.shop_pagination
:if={assigns[:pagination] && assigns[:pagination].total_pages > 1}
page={assigns[:pagination]}
base_path={~p"/collections/#{@collection_slug}"}
params={@sort_params}
/>
<%= if (assigns[:products] || []) == [] do %>
<div class="collection-empty">
<p>No products found in this collection.</p>