add pagination across all admin and shop views
All checks were successful
deploy / deploy (push) Successful in 1m38s
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:
parent
7f6fd012a5
commit
3480b326a9
@ -22,6 +22,8 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar-wrapper {
|
.admin-sidebar-wrapper {
|
||||||
@ -249,6 +251,11 @@
|
|||||||
|
|
||||||
/* ── Tables ── */
|
/* ── Tables ── */
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@ -2125,12 +2132,15 @@
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-grid-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.media-grid {
|
.media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2469,4 +2479,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
|
||||||
|
.admin-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--t-surface-sunken, var(--color-base-200));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination-showing {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: color-mix(in oklch, var(--t-text-primary) 55%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination-ellipsis {
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: color-mix(in oklch, var(--t-text-primary) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 35.99em) {
|
||||||
|
.admin-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination-showing {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} /* @layer admin */
|
} /* @layer admin */
|
||||||
|
|||||||
@ -467,6 +467,102 @@
|
|||||||
font-size: var(--t-text-small);
|
font-size: var(--t-text-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shop pagination ── */
|
||||||
|
|
||||||
|
.shop-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-showing {
|
||||||
|
font-size: var(--t-text-small);
|
||||||
|
color: var(--t-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
font-size: var(--t-text-small);
|
||||||
|
color: var(--t-text-primary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--t-radius-button, 0.375rem);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--t-surface-sunken, var(--t-surface-alt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-btn-active {
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-text-inverse, #fff);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--t-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-btn-disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-ellipsis {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
font-size: var(--t-text-small);
|
||||||
|
color: var(--t-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 35.99em) {
|
||||||
|
.shop-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-showing {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-btn {
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-pagination-ellipsis {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Search page ── */
|
/* ── Search page ── */
|
||||||
|
|
||||||
.search-page-form {
|
.search-page-form {
|
||||||
|
|||||||
@ -213,6 +213,20 @@ defmodule Berrypod.Media do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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, nil), do: query
|
||||||
defp filter_by_type(query, ""), do: query
|
defp filter_by_type(query, ""), do: query
|
||||||
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)
|
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)
|
||||||
|
|||||||
@ -162,6 +162,29 @@ defmodule Berrypod.Newsletter do
|
|||||||
Repo.all(query)
|
Repo.all(query)
|
||||||
end
|
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."
|
@doc "Returns subscriber counts grouped by status."
|
||||||
def count_subscribers_by_status do
|
def count_subscribers_by_status do
|
||||||
from(s in Subscriber,
|
from(s in Subscriber,
|
||||||
@ -228,6 +251,11 @@ defmodule Berrypod.Newsletter do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 get_campaign!(id), do: Repo.get!(Campaign, id)
|
||||||
|
|
||||||
def create_campaign(attrs) do
|
def create_campaign(attrs) do
|
||||||
|
|||||||
@ -146,6 +146,7 @@ defmodule Berrypod.Newsletter.Notifier do
|
|||||||
@doc false
|
@doc false
|
||||||
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
|
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
|
||||||
logo_html = build_logo_html(shop_name)
|
logo_html = build_logo_html(shop_name)
|
||||||
|
|
||||||
footer =
|
footer =
|
||||||
if unsubscribe_url do
|
if unsubscribe_url do
|
||||||
"""
|
"""
|
||||||
@ -244,7 +245,10 @@ defmodule Berrypod.Newsletter.Notifier do
|
|||||||
|
|
||||||
defp has_favicon_icon? do
|
defp has_favicon_icon? do
|
||||||
require Ecto.Query
|
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
|
end
|
||||||
|
|
||||||
# Turns bare URLs in escaped text into clickable links.
|
# Turns bare URLs in escaped text into clickable links.
|
||||||
|
|||||||
@ -35,6 +35,17 @@ defmodule Berrypod.Orders do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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, nil), do: query
|
||||||
defp maybe_filter_status(query, "all"), do: query
|
defp maybe_filter_status(query, "all"), do: query
|
||||||
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)
|
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)
|
||||||
|
|||||||
119
lib/berrypod/pagination.ex
Normal file
119
lib/berrypod/pagination.ex
Normal 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
|
||||||
@ -171,6 +171,21 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Lists distinct categories from visible, active products.
|
Lists distinct categories from visible, active products.
|
||||||
Returns a list of `%{name, slug, image_url}` where `image_url` is the
|
Returns a list of `%{name, slug, image_url}` where `image_url` is the
|
||||||
@ -308,6 +323,20 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns distinct category names from all products (including hidden/draft).
|
Returns distinct category names from all products (including hidden/draft).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -217,6 +217,11 @@ defmodule Berrypod.Redirects do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Gets a single redirect by ID.
|
Gets a single redirect by ID.
|
||||||
"""
|
"""
|
||||||
@ -275,6 +280,16 @@ defmodule Berrypod.Redirects do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Resolves a broken URL by creating a redirect and updating the record.
|
Resolves a broken URL by creating a redirect and updating the record.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -333,34 +333,36 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
end
|
end
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<table class="admin-table admin-table-zebra">
|
<div class="admin-table-wrap">
|
||||||
<thead>
|
<table class="admin-table admin-table-zebra">
|
||||||
<tr>
|
<thead>
|
||||||
<th :for={col <- @col}>{col[:label]}</th>
|
<tr>
|
||||||
<th :if={@action != []}>
|
<th :for={col <- @col}>{col[:label]}</th>
|
||||||
<span class="sr-only">{gettext("Actions")}</span>
|
<th :if={@action != []}>
|
||||||
</th>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
</thead>
|
||||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
<td
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||||
:for={col <- @col}
|
<td
|
||||||
phx-click={@row_click && @row_click.(row)}
|
:for={col <- @col}
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
phx-click={@row_click && @row_click.(row)}
|
||||||
>
|
class={@row_click && "hover:cursor-pointer"}
|
||||||
{render_slot(col, @row_item.(row))}
|
>
|
||||||
</td>
|
{render_slot(col, @row_item.(row))}
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
</td>
|
||||||
<div class="flex gap-4">
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
<%= for action <- @action do %>
|
<div class="flex gap-4">
|
||||||
{render_slot(action, @row_item.(row))}
|
<%= for action <- @action do %>
|
||||||
<% end %>
|
{render_slot(action, @row_item.(row))}
|
||||||
</div>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -622,4 +624,88 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
|> JS.exec("close()", to: "##{id}")
|
|> JS.exec("close()", to: "##{id}")
|
||||||
|> JS.pop_focus()
|
|> JS.pop_focus()
|
||||||
end
|
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">…</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
|
end
|
||||||
|
|||||||
@ -1714,4 +1714,84 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
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"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<%= for item <- @numbers do %>
|
||||||
|
<%= case item do %>
|
||||||
|
<% :ellipsis -> %>
|
||||||
|
<span class="shop-pagination-ellipsis" aria-hidden="true">…</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 ›
|
||||||
|
</.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
|
end
|
||||||
|
|||||||
@ -5,20 +5,18 @@ defmodule BerrypodWeb.Admin.Media do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
images = Media.list_images()
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Media")
|
|> assign(:page_title, "Media")
|
||||||
|> assign(:filter_type, nil)
|
|> assign(:filter_type, nil)
|
||||||
|> assign(:filter_search, "")
|
|> assign(:filter_search, "")
|
||||||
|> assign(:filter_orphans, false)
|
|> assign(:filter_orphans, false)
|
||||||
|
|> assign(:pagination, nil)
|
||||||
|> assign(:selected_image, nil)
|
|> assign(:selected_image, nil)
|
||||||
|> assign(:selected_usages, [])
|
|> assign(:selected_usages, [])
|
||||||
|> assign(:edit_form, nil)
|
|> assign(:edit_form, nil)
|
||||||
|> assign(:upload_alt, "")
|
|> assign(:upload_alt, "")
|
||||||
|> assign(:confirm_delete, false)
|
|> assign(:confirm_delete, false)
|
||||||
|> stream(:images, images)
|
|
||||||
|> allow_upload(:media_upload,
|
|> allow_upload(:media_upload,
|
||||||
accept: ~w(.png .jpg .jpeg .webp .svg .gif),
|
accept: ~w(.png .jpg .jpeg .webp .svg .gif),
|
||||||
max_entries: 1,
|
max_entries: 1,
|
||||||
@ -30,6 +28,20 @@ defmodule BerrypodWeb.Admin.Media do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
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
|
defp handle_progress(:media_upload, entry, socket) do
|
||||||
if entry.done? do
|
if entry.done? do
|
||||||
alt = socket.assigns.upload_alt
|
alt = socket.assigns.upload_alt
|
||||||
@ -60,15 +72,25 @@ defmodule BerrypodWeb.Admin.Media do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter_type", %{"type" => type}, socket) do
|
def handle_event("filter_type", %{"type" => type}, socket) do
|
||||||
type = if type == "", do: nil, else: type
|
type = if type == "", do: nil, else: type
|
||||||
{:noreply, reload_images(assign(socket, :filter_type, type))}
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:filter_type, type)
|
||||||
|
|> reload_images()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("filter_search", %{"value" => value}, socket) do
|
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
|
end
|
||||||
|
|
||||||
def handle_event("toggle_orphans", _params, socket) do
|
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
|
end
|
||||||
|
|
||||||
def handle_event("select_image", %{"id" => id}, socket) do
|
def handle_event("select_image", %{"id" => id}, socket) do
|
||||||
@ -159,26 +181,29 @@ defmodule BerrypodWeb.Admin.Media do
|
|||||||
|
|
||||||
# ── Private helpers ──────────────────────────────────────────────
|
# ── 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
|
defp reload_images(socket) do
|
||||||
opts =
|
if socket.assigns.filter_orphans do
|
||||||
[
|
# Orphan mode: load all, filter in Elixir (no pagination)
|
||||||
type: socket.assigns.filter_type,
|
opts = image_filter_opts(socket)
|
||||||
search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search),
|
images = Media.list_images(opts)
|
||||||
tag: nil
|
used = Media.used_image_ids()
|
||||||
]
|
orphans = Enum.reject(images, &MapSet.member?(used, &1.id))
|
||||||
|> Enum.reject(fn {_, v} -> is_nil(v) end)
|
|
||||||
|
|
||||||
images = Media.list_images(opts)
|
socket
|
||||||
|
|> assign(:pagination, nil)
|
||||||
images =
|
|> stream(:images, orphans, reset: true)
|
||||||
if socket.assigns.filter_orphans do
|
else
|
||||||
used = Media.used_image_ids()
|
push_patch(socket, to: ~p"/admin/media")
|
||||||
Enum.reject(images, &MapSet.member?(used, &1.id))
|
end
|
||||||
else
|
|
||||||
images
|
|
||||||
end
|
|
||||||
|
|
||||||
stream(socket, :images, images, reset: true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_file_size(nil), do: "—"
|
defp format_file_size(nil), do: "—"
|
||||||
@ -284,49 +309,53 @@ defmodule BerrypodWeb.Admin.Media do
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="media-main">
|
<div class="media-main">
|
||||||
<%!-- image grid --%>
|
<%!-- image grid + pagination --%>
|
||||||
<div id="media-grid" phx-update="stream" class="media-grid">
|
<div class="media-grid-wrapper">
|
||||||
<div
|
<div id="media-grid" phx-update="stream" class="media-grid">
|
||||||
:for={{dom_id, image} <- @streams.images}
|
<div
|
||||||
id={dom_id}
|
:for={{dom_id, image} <- @streams.images}
|
||||||
phx-click="select_image"
|
id={dom_id}
|
||||||
phx-value-id={image.id}
|
phx-click="select_image"
|
||||||
class={[
|
phx-value-id={image.id}
|
||||||
"media-card",
|
class={[
|
||||||
@selected_image && @selected_image.id == image.id && "media-card-selected"
|
"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-thumb">
|
||||||
<div class="media-card-svg-placeholder">
|
<%= if image.is_svg do %>
|
||||||
<.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">
|
<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>
|
</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 %>
|
||||||
<% 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>
|
</div>
|
||||||
<span
|
<div class="media-card-info">
|
||||||
:if={!image.alt || image.alt == ""}
|
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
|
||||||
class="media-card-no-alt"
|
<div class="media-card-meta">
|
||||||
title="Missing alt text"
|
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
|
||||||
>
|
<span class="text-xs">{format_file_size(image.file_size)}</span>
|
||||||
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
|
</div>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- detail panel --%>
|
<%!-- detail panel --%>
|
||||||
|
|||||||
@ -6,8 +6,8 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
counts = Newsletter.count_subscribers_by_status()
|
counts = Newsletter.count_subscribers_by_status()
|
||||||
subscribers = Newsletter.list_subscribers()
|
sub_page = Newsletter.list_subscribers_paginated(page: 1)
|
||||||
campaigns = Newsletter.list_campaigns()
|
camp_page = Newsletter.list_campaigns_paginated(page: 1)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
@ -15,38 +15,47 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
|> assign(:tab, "overview")
|
|> assign(:tab, "overview")
|
||||||
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|
||||||
|> assign(:status_counts, counts)
|
|> assign(:status_counts, counts)
|
||||||
|> assign(:subscriber_count, length(subscribers))
|
|> assign(:subscriber_pagination, sub_page)
|
||||||
|> assign(:campaign_count, length(campaigns))
|
|> assign(:subscriber_count, sub_page.total_count)
|
||||||
|
|> assign(:campaign_pagination, camp_page)
|
||||||
|
|> assign(:campaign_count, camp_page.total_count)
|
||||||
|> assign(:status_filter, "all")
|
|> assign(:status_filter, "all")
|
||||||
|> assign(:search, "")
|
|> assign(:search, "")
|
||||||
|> stream(:subscribers, subscribers)
|
|> stream(:subscribers, sub_page.items)
|
||||||
|> stream(:campaigns, campaigns)}
|
|> stream(:campaigns, camp_page.items)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"tab" => tab}, _uri, socket)
|
def handle_params(params, _uri, socket) do
|
||||||
when tab in ~w(overview subscribers campaigns) 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 = assign(socket, :tab, tab)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
case tab do
|
case tab do
|
||||||
"subscribers" ->
|
"subscribers" ->
|
||||||
subscribers =
|
page =
|
||||||
Newsletter.list_subscribers(
|
Newsletter.list_subscribers_paginated(
|
||||||
status: socket.assigns.status_filter,
|
status: socket.assigns.status_filter,
|
||||||
search: socket.assigns.search
|
search: socket.assigns.search,
|
||||||
|
page: page_num
|
||||||
)
|
)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:subscriber_count, length(subscribers))
|
|> assign(:subscriber_pagination, page)
|
||||||
|> stream(:subscribers, subscribers, reset: true)
|
|> assign(:subscriber_count, page.total_count)
|
||||||
|
|> stream(:subscribers, page.items, reset: true)
|
||||||
|
|
||||||
"campaigns" ->
|
"campaigns" ->
|
||||||
campaigns = Newsletter.list_campaigns()
|
page = Newsletter.list_campaigns_paginated(page: page_num)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:campaign_count, length(campaigns))
|
|> assign(:campaign_pagination, page)
|
||||||
|> stream(:campaigns, campaigns, reset: true)
|
|> assign(:campaign_count, page.total_count)
|
||||||
|
|> stream(:campaigns, page.items, reset: true)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
socket
|
socket
|
||||||
@ -55,8 +64,6 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_enabled", _params, socket) do
|
def handle_event("toggle_enabled", _params, socket) do
|
||||||
new_value = !socket.assigns.newsletter_enabled
|
new_value = !socket.assigns.newsletter_enabled
|
||||||
@ -71,23 +78,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("filter_subscribers", %{"status" => status}, socket) do
|
def handle_event("filter_subscribers", %{"status" => status}, socket) do
|
||||||
subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:status_filter, status)
|
|> assign(:status_filter, status)
|
||||||
|> assign(:subscriber_count, length(subscribers))
|
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|
||||||
|> stream(:subscribers, subscribers, reset: true)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("search_subscribers", %{"search" => term}, socket) do
|
def handle_event("search_subscribers", %{"search" => term}, socket) do
|
||||||
subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:search, term)
|
|> assign(:search, term)
|
||||||
|> assign(:subscriber_count, length(subscribers))
|
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|
||||||
|> stream(:subscribers, subscribers, reset: true)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("delete_subscriber", %{"id" => id}, socket) do
|
def handle_event("delete_subscriber", %{"id" => id}, socket) do
|
||||||
@ -152,12 +153,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
status_filter={@status_filter}
|
status_filter={@status_filter}
|
||||||
status_counts={@status_counts}
|
status_counts={@status_counts}
|
||||||
subscriber_count={@subscriber_count}
|
subscriber_count={@subscriber_count}
|
||||||
|
subscriber_pagination={@subscriber_pagination}
|
||||||
search={@search}
|
search={@search}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@tab == "campaigns"}>
|
<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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@ -254,6 +260,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
attr :status_filter, :string, required: true
|
attr :status_filter, :string, required: true
|
||||||
attr :status_counts, :map, required: true
|
attr :status_counts, :map, required: true
|
||||||
attr :subscriber_count, :integer, required: true
|
attr :subscriber_count, :integer, required: true
|
||||||
|
attr :subscriber_pagination, Berrypod.Pagination, required: true
|
||||||
attr :search, :string, required: true
|
attr :search, :string, required: true
|
||||||
|
|
||||||
defp subscribers_tab(assigns) do
|
defp subscribers_tab(assigns) do
|
||||||
@ -323,6 +330,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.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">
|
<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" />
|
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
|
||||||
<p class="text-lg font-medium">No subscribers yet</p>
|
<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 :streams, :any, required: true
|
||||||
attr :campaign_count, :integer, required: true
|
attr :campaign_count, :integer, required: true
|
||||||
|
attr :campaign_pagination, Berrypod.Pagination, required: true
|
||||||
|
|
||||||
defp campaigns_tab(assigns) do
|
defp campaigns_tab(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -370,6 +385,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
|
|||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.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">
|
<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" />
|
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
|
||||||
<p class="text-lg font-medium">No campaigns yet</p>
|
<p class="text-lg font-medium">No campaigns yet</p>
|
||||||
|
|||||||
@ -7,32 +7,38 @@ defmodule BerrypodWeb.Admin.Orders do
|
|||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
counts = Orders.count_orders_by_status()
|
counts = Orders.count_orders_by_status()
|
||||||
orders = Orders.list_orders()
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Orders")
|
|> assign(:page_title, "Orders")
|
||||||
|> assign(:status_filter, "all")
|
|> assign(:status_filter, "all")
|
||||||
|> assign(:status_counts, counts)
|
|> assign(:status_counts, counts)
|
||||||
|> assign(:order_count, length(orders))
|
|
||||||
|> stream(:orders, orders)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", %{"status" => status}, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
orders = Orders.list_orders(status: status)
|
page_num = Berrypod.Pagination.parse_page(params)
|
||||||
|
page = Orders.list_orders_paginated(status: socket.assigns.status_filter, page: page_num)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:status_filter, status)
|
|> assign(:pagination, page)
|
||||||
|> assign(:order_count, length(orders))
|
|> assign(:order_count, page.total_count)
|
||||||
|> stream(:orders, orders, reset: true)
|
|> stream(:orders, page.items, reset: true)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -90,6 +96,8 @@ defmodule BerrypodWeb.Admin.Orders do
|
|||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.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">
|
<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" />
|
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
|
||||||
<p class="text-lg font-medium">No orders yet</p>
|
<p class="text-lg font-medium">No orders yet</p>
|
||||||
|
|||||||
@ -9,55 +9,58 @@ defmodule BerrypodWeb.Admin.Products do
|
|||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
connections = Products.list_provider_connections()
|
connections = Products.list_provider_connections()
|
||||||
categories = Products.list_all_categories()
|
categories = Products.list_all_categories()
|
||||||
products = Products.list_products_admin()
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Products")
|
|> assign(:page_title, "Products")
|
||||||
|> assign(:connections, connections)
|
|> assign(:connections, connections)
|
||||||
|> assign(:categories, categories)
|
|> assign(:categories, categories)
|
||||||
|> assign(:product_count, length(products))
|
|
||||||
|> assign(:provider_filter, "all")
|
|> assign(:provider_filter, "all")
|
||||||
|> assign(:category_filter, "all")
|
|> assign(:category_filter, "all")
|
||||||
|> assign(:visibility_filter, "all")
|
|> assign(:visibility_filter, "all")
|
||||||
|> assign(:stock_filter, "all")
|
|> assign(:stock_filter, "all")
|
||||||
|> assign(:sort, "newest")
|
|> assign(:sort, "newest")
|
||||||
|> stream(:products, products)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", params, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
provider_filter = params["provider"] || socket.assigns.provider_filter
|
page_num = Berrypod.Pagination.parse_page(params)
|
||||||
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
|
|
||||||
|
|
||||||
opts =
|
opts =
|
||||||
[]
|
build_filter_opts(
|
||||||
|> maybe_add_filter(:provider_connection_id, provider_filter)
|
socket.assigns.provider_filter,
|
||||||
|> maybe_add_filter(:category, category_filter)
|
socket.assigns.category_filter,
|
||||||
|> maybe_add_visibility(visibility_filter)
|
socket.assigns.visibility_filter,
|
||||||
|> maybe_add_stock(stock_filter)
|
socket.assigns.stock_filter,
|
||||||
|> Keyword.put(:sort, sort)
|
socket.assigns.sort
|
||||||
|
)
|
||||||
|
|
||||||
products = Products.list_products_admin(opts)
|
page = Products.list_products_admin_paginated([page: page_num] ++ opts)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:provider_filter, provider_filter)
|
|> assign(:pagination, page)
|
||||||
|> assign(:category_filter, category_filter)
|
|> assign(:product_count, page.total_count)
|
||||||
|> assign(:visibility_filter, visibility_filter)
|
|> stream(:products, page.items, reset: true)
|
||||||
|> assign(:stock_filter, stock_filter)
|
|
||||||
|> assign(:sort, sort)
|
|
||||||
|> assign(:product_count, length(products))
|
|
||||||
|> stream(:products, products, reset: true)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
||||||
product =
|
product =
|
||||||
@ -177,6 +180,8 @@ defmodule BerrypodWeb.Admin.Products do
|
|||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.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">
|
<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" />
|
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
|
||||||
<p class="text-lg font-medium">No products yet</p>
|
<p class="text-lg font-medium">No products yet</p>
|
||||||
@ -272,6 +277,15 @@ defmodule BerrypodWeb.Admin.Products do
|
|||||||
# Filter helpers
|
# 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, "all"), do: opts
|
||||||
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)
|
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)
|
||||||
|
|
||||||
|
|||||||
@ -9,11 +9,16 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket), do: Redirects.subscribe()
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Redirects")
|
|> assign(:page_title, "Redirects")
|
||||||
|> assign(:redirects, Redirects.list_redirects())
|
|> assign(:redirect_pagination, redirect_page)
|
||||||
|> assign(:broken_urls, Redirects.list_broken_urls())
|
|> assign(:broken_url_pagination, broken_page)
|
||||||
|
|> stream(:redirects, redirect_page.items)
|
||||||
|
|> stream(:broken_urls, broken_page.items)
|
||||||
|> assign(
|
|> assign(
|
||||||
:form,
|
:form,
|
||||||
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
|
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
|
||||||
@ -25,16 +30,50 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects"
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:redirects_changed, _action}, socket) do
|
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
|
end
|
||||||
|
|
||||||
def handle_info({:broken_urls_changed, _path}, socket) do
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -43,10 +82,15 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("delete_redirect", %{"id" => id}, socket) do
|
def handle_event("delete_redirect", %{"id" => id}, socket) do
|
||||||
redirect = Redirects.get_redirect!(id)
|
redirect_rec = Redirects.get_redirect!(id)
|
||||||
{:ok, _} = Redirects.delete_redirect(redirect)
|
{: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
|
end
|
||||||
|
|
||||||
def handle_event("create_redirect", %{"redirect" => params}, socket) do
|
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)
|
broken_url = Redirects.get_broken_url!(id)
|
||||||
{:ok, _} = Redirects.ignore_broken_url(broken_url)
|
{: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
|
end
|
||||||
|
|
||||||
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
|
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
|
||||||
@ -103,17 +152,27 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
<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_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} />
|
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} />
|
<.tab_button tab="create" label="Create" active={@tab} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @tab == "redirects" do %>
|
<%= if @tab == "redirects" do %>
|
||||||
<.redirects_table redirects={@redirects} />
|
<.redirects_table streams={@streams} pagination={@redirect_pagination} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @tab == "broken" do %>
|
<%= if @tab == "broken" do %>
|
||||||
<.broken_urls_table broken_urls={@broken_urls} />
|
<.broken_urls_table streams={@streams} pagination={@broken_url_pagination} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @tab == "create" do %>
|
<%= if @tab == "create" do %>
|
||||||
@ -124,23 +183,23 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
|
|
||||||
defp redirects_table(assigns) do
|
defp redirects_table(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @redirects == [] do %>
|
<%= if @pagination.total_count == 0 do %>
|
||||||
<p>No redirects yet.</p>
|
<p>No redirects yet.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<table class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
<thead>
|
<table class="admin-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>From</th>
|
|
||||||
<th>To</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Hits</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<%= for redirect <- @redirects do %>
|
|
||||||
<tr>
|
<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.from_path}</code></td>
|
||||||
<td><code>{redirect.to_path}</code></td>
|
<td><code>{redirect.to_path}</code></td>
|
||||||
<td>
|
<td>
|
||||||
@ -161,32 +220,38 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
|
|
||||||
|
<.admin_pagination
|
||||||
|
page={@pagination}
|
||||||
|
patch={~p"/admin/redirects"}
|
||||||
|
params={%{"tab" => "redirects"}}
|
||||||
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp broken_urls_table(assigns) do
|
defp broken_urls_table(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @broken_urls == [] do %>
|
<%= if @pagination.total_count == 0 do %>
|
||||||
<p>No broken URLs detected.</p>
|
<p>No broken URLs detected.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<table class="admin-table">
|
<div class="admin-table-wrap">
|
||||||
<thead>
|
<table class="admin-table">
|
||||||
<tr>
|
<thead>
|
||||||
<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 %>
|
|
||||||
<tr>
|
<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><code>{broken_url.path}</code></td>
|
||||||
<td>{broken_url.prior_analytics_hits}</td>
|
<td>{broken_url.prior_analytics_hits}</td>
|
||||||
<td>{broken_url.recent_404_count}</td>
|
<td>{broken_url.recent_404_count}</td>
|
||||||
@ -209,9 +274,15 @@ defmodule BerrypodWeb.Admin.Redirects do
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
|
|
||||||
|
<.admin_pagination
|
||||||
|
page={@pagination}
|
||||||
|
patch={~p"/admin/redirects"}
|
||||||
|
params={%{"tab" => "broken"}}
|
||||||
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
defmodule BerrypodWeb.Shop.Collection do
|
defmodule BerrypodWeb.Shop.Collection do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.{Pages, Products}
|
alias Berrypod.{Pages, Pagination, Products}
|
||||||
|
|
||||||
@sort_options [
|
@sort_options [
|
||||||
{"featured", "Featured"},
|
{"featured", "Featured"},
|
||||||
@ -28,18 +28,21 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
||||||
sort = params["sort"] || "featured"
|
sort = params["sort"] || "featured"
|
||||||
|
page_num = Pagination.parse_page(params)
|
||||||
|
|
||||||
case load_collection(slug, sort) do
|
case load_collection(slug, sort, page_num) do
|
||||||
{:ok, title, category, products} ->
|
{:ok, title, category, pagination} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, title)
|
|> assign(:page_title, title)
|
||||||
|> assign(:page_description, collection_description(title))
|
|> assign(:page_description, collection_description(title))
|
||||||
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|
||||||
|> assign(:collection_title, title)
|
|> assign(:collection_title, title)
|
||||||
|
|> assign(:collection_slug, slug)
|
||||||
|> assign(:current_category, category)
|
|> assign(:current_category, category)
|
||||||
|> assign(:current_sort, sort)
|
|> assign(:current_sort, sort)
|
||||||
|> assign(:products, products)}
|
|> assign(:pagination, pagination)
|
||||||
|
|> assign(:products, pagination.items)}
|
||||||
|
|
||||||
:not_found ->
|
:not_found ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
@ -49,22 +52,30 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_collection("all", sort) do
|
defp load_collection("all", sort, page) do
|
||||||
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
|
pagination = Products.list_visible_products_paginated(sort: sort, page: page)
|
||||||
|
{:ok, "All Products", nil, pagination}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_collection("sale", sort) do
|
defp load_collection("sale", sort, page) do
|
||||||
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
|
pagination = Products.list_visible_products_paginated(on_sale: true, sort: sort, page: page)
|
||||||
|
{:ok, "Sale", :sale, pagination}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_collection(slug, sort) do
|
defp load_collection(slug, sort, page) do
|
||||||
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
||||||
nil ->
|
nil ->
|
||||||
:not_found
|
:not_found
|
||||||
|
|
||||||
category ->
|
category ->
|
||||||
products = Products.list_visible_products(category: category.name, sort: sort)
|
pagination =
|
||||||
{:ok, category.name, category, products}
|
Products.list_visible_products_paginated(
|
||||||
|
category: category.name,
|
||||||
|
sort: sort,
|
||||||
|
page: page
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, category.name, category, pagination}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -439,10 +439,19 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
# ── Collection blocks ───────────────────────────────────────────
|
# ── Collection blocks ───────────────────────────────────────────
|
||||||
|
|
||||||
defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do
|
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"""
|
~H"""
|
||||||
<.collection_header
|
<.collection_header
|
||||||
title={assigns[:collection_title] || "All Products"}
|
title={assigns[:collection_title] || "All Products"}
|
||||||
product_count={length(assigns[:products] || [])}
|
product_count={@product_count}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@ -525,7 +534,15 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
|
|
||||||
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
|
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
|
||||||
show_category = assigns[:current_category] in [nil, :sale]
|
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"""
|
~H"""
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
@ -541,6 +558,13 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</.product_grid>
|
</.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 %>
|
<%= if (assigns[:products] || []) == [] do %>
|
||||||
<div class="collection-empty">
|
<div class="collection-empty">
|
||||||
<p>No products found in this collection.</p>
|
<p>No products found in this collection.</p>
|
||||||
|
|||||||
399
priv/repo/dev_seeds.exs
Normal file
399
priv/repo/dev_seeds.exs
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
# Dev seed data for testing pagination.
|
||||||
|
#
|
||||||
|
# mix run priv/repo/dev_seeds.exs
|
||||||
|
#
|
||||||
|
# Creates enough records to exercise pagination in every admin and shop view.
|
||||||
|
# Safe to run multiple times — skips if data already exists.
|
||||||
|
|
||||||
|
alias Berrypod.Repo
|
||||||
|
alias Berrypod.Products
|
||||||
|
alias Berrypod.Products.{Product, ProductImage, ProductVariant, ProviderConnection}
|
||||||
|
alias Berrypod.Orders
|
||||||
|
alias Berrypod.Orders.{Order, OrderItem}
|
||||||
|
alias Berrypod.Media.Image
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
alias Berrypod.Redirects
|
||||||
|
alias Berrypod.Redirects.{Redirect, BrokenUrl}
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
# ── Helpers ──
|
||||||
|
|
||||||
|
defmodule DevSeeds do
|
||||||
|
@adj ~w[rustic vintage modern classic artisan handmade organic natural premium deluxe]
|
||||||
|
@noun ~w[mug poster print tote hoodie tee cap sticker notebook candle]
|
||||||
|
@colour ~w[crimson slate sage coral midnight forest amber ivory charcoal dusty-rose]
|
||||||
|
@size ~w[XS S M L XL 2XL]
|
||||||
|
|
||||||
|
@first_names ~w[Alice Bob Charlie Diana Ethan Fiona George Hannah Ivan Julia
|
||||||
|
Kevin Laura Mike Nora Oscar Penny Quinn Rosa Sam Tina]
|
||||||
|
@last_names ~w[Smith Jones Brown Taylor Wilson Davies Evans Thomas Roberts Johnson
|
||||||
|
Walker White Harris Martin Thompson Garcia Martinez Robinson Clark Lewis]
|
||||||
|
@domains ~w[gmail.com outlook.com yahoo.co.uk hotmail.com protonmail.com icloud.com]
|
||||||
|
|
||||||
|
@campaign_subjects [
|
||||||
|
"New arrivals just dropped",
|
||||||
|
"Spring sale — 20% off everything",
|
||||||
|
"Your exclusive early access",
|
||||||
|
"Back in stock: fan favourites",
|
||||||
|
"Free shipping this weekend only",
|
||||||
|
"Our story so far",
|
||||||
|
"Last chance: sale ends tonight",
|
||||||
|
"Meet the maker",
|
||||||
|
"Gift guide for every budget",
|
||||||
|
"Something special is coming"
|
||||||
|
]
|
||||||
|
|
||||||
|
def product_title(i) do
|
||||||
|
adj = Enum.at(@adj, rem(i, length(@adj)))
|
||||||
|
noun = Enum.at(@noun, rem(div(i, length(@adj)), length(@noun)))
|
||||||
|
"#{String.capitalize(adj)} #{noun}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def category(i) do
|
||||||
|
cats = ["Apparel", "Homeware", "Stationery", "Accessories", "Art Prints"]
|
||||||
|
Enum.at(cats, rem(i, length(cats)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def colour(i), do: Enum.at(@colour, rem(i, length(@colour)))
|
||||||
|
def size(i), do: Enum.at(@size, rem(i, length(@size)))
|
||||||
|
|
||||||
|
def customer_email(i) do
|
||||||
|
first = Enum.at(@first_names, rem(i, length(@first_names)))
|
||||||
|
last = Enum.at(@last_names, rem(div(i, length(@first_names)), length(@last_names)))
|
||||||
|
domain = Enum.at(@domains, rem(i, length(@domains)))
|
||||||
|
"#{String.downcase(first)}.#{String.downcase(last)}@#{domain}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscriber_email(i) do
|
||||||
|
first = Enum.at(@first_names, rem(i, length(@first_names)))
|
||||||
|
last = Enum.at(@last_names, rem(div(i + 3, length(@first_names)), length(@last_names)))
|
||||||
|
domain = Enum.at(@domains, rem(i + 2, length(@domains)))
|
||||||
|
"#{String.downcase(first)}.#{String.downcase(last)}+news@#{domain}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def campaign_subject(i) do
|
||||||
|
base = Enum.at(@campaign_subjects, rem(i, length(@campaign_subjects)))
|
||||||
|
if i >= length(@campaign_subjects), do: "#{base} (#{div(i, length(@campaign_subjects)) + 1})", else: base
|
||||||
|
end
|
||||||
|
|
||||||
|
def random_price, do: Enum.random(999..4999)
|
||||||
|
def random_cost(price), do: div(price, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Guard: skip if data already exists ──
|
||||||
|
|
||||||
|
existing_products = Repo.aggregate(Product, :count)
|
||||||
|
|
||||||
|
if existing_products >= 60 do
|
||||||
|
IO.puts("Already have #{existing_products} products — skipping dev seeds.")
|
||||||
|
else
|
||||||
|
# ── 1. Provider connection ──
|
||||||
|
|
||||||
|
IO.puts("Creating provider connection...")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
nil ->
|
||||||
|
{:ok, c} =
|
||||||
|
Products.create_provider_connection(%{
|
||||||
|
provider_type: "printify",
|
||||||
|
name: "Dev Printify Shop",
|
||||||
|
api_key: "dev_test_key_123",
|
||||||
|
config: %{"shop_id" => "dev_shop"}
|
||||||
|
})
|
||||||
|
c
|
||||||
|
|
||||||
|
existing ->
|
||||||
|
existing
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 2. Products (60 — enough for 3 pages at 25/page in admin, 3 pages at 24/page in shop) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 60 products with variants...")
|
||||||
|
|
||||||
|
# Tiny placeholder image (1x1 white pixel WebP)
|
||||||
|
pixel_webp =
|
||||||
|
<<82, 73, 70, 70, 36, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 32,
|
||||||
|
16, 0, 0, 0, 48, 1, 0, 157, 1, 42, 1, 0, 1, 0, 1, 0, 52, 37,
|
||||||
|
164, 0, 3, 112, 0, 254, 251, 148, 0, 0>>
|
||||||
|
|
||||||
|
for i <- 0..59 do
|
||||||
|
title = DevSeeds.product_title(i)
|
||||||
|
slug = Slug.slugify(title) <> "-#{i}"
|
||||||
|
price = DevSeeds.random_price()
|
||||||
|
on_sale = rem(i, 7) == 0
|
||||||
|
|
||||||
|
{:ok, product} =
|
||||||
|
Products.create_product(%{
|
||||||
|
provider_connection_id: conn.id,
|
||||||
|
provider_product_id: "dev_prod_#{i}",
|
||||||
|
title: title,
|
||||||
|
description: "A lovely #{String.downcase(title)} for your collection. Hand-picked and quality-checked.",
|
||||||
|
slug: slug,
|
||||||
|
status: "active",
|
||||||
|
visible: true,
|
||||||
|
category: DevSeeds.category(i),
|
||||||
|
cheapest_price: price,
|
||||||
|
compare_at_price: if(on_sale, do: price + Enum.random(500..1500), else: nil),
|
||||||
|
on_sale: on_sale,
|
||||||
|
in_stock: rem(i, 20) != 0,
|
||||||
|
provider_data: %{
|
||||||
|
"options" => [
|
||||||
|
%{"type" => "color", "name" => "Colour", "values" => [
|
||||||
|
%{"title" => DevSeeds.colour(i), "colors" => ["#666"]},
|
||||||
|
%{"title" => DevSeeds.colour(i + 1), "colors" => ["#999"]}
|
||||||
|
]},
|
||||||
|
%{"type" => "size", "name" => "Size", "values" => [
|
||||||
|
%{"title" => "S"}, %{"title" => "M"}, %{"title" => "L"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a product image (using a stored Image record)
|
||||||
|
{:ok, img} =
|
||||||
|
%Image{}
|
||||||
|
|> Image.changeset(%{
|
||||||
|
image_type: "product",
|
||||||
|
filename: "#{slug}.webp",
|
||||||
|
content_type: "image/webp",
|
||||||
|
file_size: byte_size(pixel_webp),
|
||||||
|
data: pixel_webp,
|
||||||
|
source_width: 1,
|
||||||
|
source_height: 1,
|
||||||
|
variants_status: "complete",
|
||||||
|
alt: title
|
||||||
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
Products.create_product_image(%{
|
||||||
|
product_id: product.id,
|
||||||
|
src: "/images/#{img.id}/original",
|
||||||
|
image_id: img.id,
|
||||||
|
position: 0,
|
||||||
|
alt: title
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create 3 variants per product
|
||||||
|
for size_idx <- 0..2 do
|
||||||
|
size = DevSeeds.size(size_idx + 1)
|
||||||
|
colour = DevSeeds.colour(i)
|
||||||
|
variant_price = price + size_idx * 200
|
||||||
|
|
||||||
|
Products.create_product_variant(%{
|
||||||
|
product_id: product.id,
|
||||||
|
provider_variant_id: "dev_var_#{i}_#{size_idx}",
|
||||||
|
title: "#{size} / #{colour}",
|
||||||
|
sku: "DEV-#{String.upcase(String.slice(slug, 0..5))}-#{size}",
|
||||||
|
price: variant_price,
|
||||||
|
compare_at_price: if(on_sale, do: variant_price + Enum.random(500..1500), else: nil),
|
||||||
|
cost: DevSeeds.random_cost(variant_price),
|
||||||
|
options: %{"Size" => size, "Colour" => colour},
|
||||||
|
is_enabled: true,
|
||||||
|
is_available: rem(i, 20) != 0
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if rem(i + 1, 20) == 0, do: IO.puts(" #{i + 1}/60 products...")
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts(" 60/60 products done.")
|
||||||
|
|
||||||
|
# ── 3. Extra media images (enough to fill 2+ pages at 36/page = 80 total, minus the 60 product images) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 30 extra media images...")
|
||||||
|
|
||||||
|
for i <- 0..29 do
|
||||||
|
%Image{}
|
||||||
|
|> Image.changeset(%{
|
||||||
|
image_type: Enum.at(["media", "header", "icon"], rem(i, 3)),
|
||||||
|
filename: "media-image-#{i}.webp",
|
||||||
|
content_type: "image/webp",
|
||||||
|
file_size: byte_size(pixel_webp),
|
||||||
|
data: pixel_webp,
|
||||||
|
source_width: 1,
|
||||||
|
source_height: 1,
|
||||||
|
variants_status: "complete",
|
||||||
|
alt: "Media image #{i + 1}",
|
||||||
|
tags: Enum.at(["banner", "promo", "lifestyle", "texture"], rem(i, 4))
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 4. Orders (60 — enough for 3 pages at 25/page) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 60 orders...")
|
||||||
|
|
||||||
|
statuses = ["paid", "paid", "paid", "pending", "failed", "refunded"]
|
||||||
|
fulfilment_statuses = ["unfulfilled", "submitted", "processing", "shipped", "delivered"]
|
||||||
|
|
||||||
|
for i <- 0..59 do
|
||||||
|
email = DevSeeds.customer_email(i)
|
||||||
|
price = DevSeeds.random_price()
|
||||||
|
qty = Enum.random(1..3)
|
||||||
|
subtotal = price * qty
|
||||||
|
payment_status = Enum.at(statuses, rem(i, length(statuses)))
|
||||||
|
|
||||||
|
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||||
|
# Spread orders over the last 90 days
|
||||||
|
days_ago = 90 - div(i * 90, 60)
|
||||||
|
inserted_at = NaiveDateTime.add(now, -days_ago * 86400, :second)
|
||||||
|
|
||||||
|
order_attrs = %{
|
||||||
|
order_number: "SS-260#{String.pad_leading("#{rem(200 + i, 300)}", 3, "0")}01-#{String.pad_leading("#{1000 + i}", 4, "0")}",
|
||||||
|
subtotal: subtotal,
|
||||||
|
shipping_cost: if(subtotal > 3000, do: 0, else: 399),
|
||||||
|
total: subtotal + if(subtotal > 3000, do: 0, else: 399),
|
||||||
|
currency: "gbp",
|
||||||
|
customer_email: email,
|
||||||
|
payment_status: payment_status,
|
||||||
|
shipping_address: %{
|
||||||
|
"name" => String.split(email, [".", "@"]) |> Enum.take(2) |> Enum.map(&String.capitalize/1) |> Enum.join(" "),
|
||||||
|
"line1" => "#{Enum.random(1..200)} #{Enum.at(~w[High Oak Elm Mill Church Park], rem(i, 6))} Street",
|
||||||
|
"city" => Enum.at(~w[London Manchester Bristol Edinburgh Cardiff Leeds], rem(i, 6)),
|
||||||
|
"postal_code" => "#{Enum.at(~w[SW1A EC2R BS1 EH1 CF10 LS1], rem(i, 6))} #{Enum.random(1..9)}#{Enum.at(~w[AA AB BA BB], rem(i, 4))}",
|
||||||
|
"country" => "GB"
|
||||||
|
},
|
||||||
|
fulfilment_status:
|
||||||
|
if(payment_status == "paid",
|
||||||
|
do: Enum.at(fulfilment_statuses, rem(i, length(fulfilment_statuses))),
|
||||||
|
else: "unfulfilled"),
|
||||||
|
inserted_at: inserted_at,
|
||||||
|
updated_at: inserted_at
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, order} =
|
||||||
|
%Order{}
|
||||||
|
|> Order.changeset(order_attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
# Create 1-2 line items
|
||||||
|
for j <- 0..(qty - 1) do
|
||||||
|
prod_idx = rem(i + j, 60)
|
||||||
|
|
||||||
|
%OrderItem{}
|
||||||
|
|> OrderItem.changeset(%{
|
||||||
|
order_id: order.id,
|
||||||
|
variant_id: "dev_var_#{prod_idx}_0",
|
||||||
|
product_id: "dev_prod_#{prod_idx}",
|
||||||
|
product_name: DevSeeds.product_title(prod_idx),
|
||||||
|
variant_title: "M / #{DevSeeds.colour(prod_idx)}",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: price
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
if rem(i + 1, 20) == 0, do: IO.puts(" #{i + 1}/60 orders...")
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts(" 60/60 orders done.")
|
||||||
|
|
||||||
|
# ── 5. Newsletter subscribers (60 — enough for 3 pages at 25/page) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 60 newsletter subscribers...")
|
||||||
|
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
for i <- 0..59 do
|
||||||
|
email = DevSeeds.subscriber_email(i)
|
||||||
|
status = Enum.at(["confirmed", "confirmed", "confirmed", "pending", "unsubscribed"], rem(i, 5))
|
||||||
|
|
||||||
|
%Newsletter.Subscriber{}
|
||||||
|
|> Newsletter.Subscriber.changeset(%{
|
||||||
|
email: email,
|
||||||
|
status: status,
|
||||||
|
confirmed_at: if(status == "confirmed", do: now, else: nil),
|
||||||
|
unsubscribed_at: if(status == "unsubscribed", do: now, else: nil),
|
||||||
|
consent_text: "Signed up via dev seeds",
|
||||||
|
source: Enum.at(["website", "checkout", "import"], rem(i, 3))
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 6. Newsletter campaigns (30 — enough for 2 pages at 25/page) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 30 newsletter campaigns...")
|
||||||
|
|
||||||
|
for i <- 0..29 do
|
||||||
|
status = Enum.at(["draft", "sent", "sent", "sent", "scheduled"], rem(i, 5))
|
||||||
|
|
||||||
|
%Newsletter.Campaign{}
|
||||||
|
|> Newsletter.Campaign.changeset(%{
|
||||||
|
subject: DevSeeds.campaign_subject(i),
|
||||||
|
body: """
|
||||||
|
Hi there!
|
||||||
|
|
||||||
|
#{DevSeeds.campaign_subject(i)} — check out what's new in the shop.
|
||||||
|
|
||||||
|
Visit us at https://example.com
|
||||||
|
|
||||||
|
Unsubscribe: {{unsubscribe_url}}
|
||||||
|
""",
|
||||||
|
status: status,
|
||||||
|
sent_at: if(status == "sent", do: now, else: nil),
|
||||||
|
sent_count: if(status == "sent", do: Enum.random(20..55), else: 0),
|
||||||
|
failed_count: if(status == "sent", do: Enum.random(0..3), else: 0),
|
||||||
|
scheduled_at: if(status == "scheduled", do: DateTime.add(now, 7, :day), else: nil)
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 7. Redirects (30 — enough for 2 pages at 25/page) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 30 redirects...")
|
||||||
|
|
||||||
|
Redirects.create_table()
|
||||||
|
|
||||||
|
for i <- 0..29 do
|
||||||
|
source = Enum.at(["auto_slug_change", "admin", "auto_product_deleted", "analytics_auto_resolved"], rem(i, 4))
|
||||||
|
old_slug = "old-product-#{i}-#{:rand.uniform(9999)}"
|
||||||
|
new_slug = "new-product-#{i}"
|
||||||
|
|
||||||
|
%Redirect{}
|
||||||
|
|> Redirect.changeset(%{
|
||||||
|
from_path: "/products/#{old_slug}",
|
||||||
|
to_path: "/products/#{new_slug}",
|
||||||
|
status_code: if(rem(i, 5) == 0, do: 302, else: 301),
|
||||||
|
source: source,
|
||||||
|
hit_count: Enum.random(0..150)
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 8. Broken URLs (30 — enough for 2 pages at 25/page) ──
|
||||||
|
|
||||||
|
IO.puts("Creating 30 broken URLs...")
|
||||||
|
|
||||||
|
for i <- 0..29 do
|
||||||
|
days_ago = Enum.random(1..60)
|
||||||
|
first_seen = DateTime.add(now, -days_ago * 86400, :second)
|
||||||
|
|
||||||
|
%BrokenUrl{}
|
||||||
|
|> BrokenUrl.changeset(%{
|
||||||
|
path: "/products/missing-item-#{i}-#{:rand.uniform(9999)}",
|
||||||
|
prior_analytics_hits: Enum.random(0..500),
|
||||||
|
recent_404_count: Enum.random(1..50),
|
||||||
|
first_seen_at: first_seen,
|
||||||
|
last_seen_at: DateTime.add(first_seen, Enum.random(0..days_ago) * 86400, :second),
|
||||||
|
status: "pending"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Warm the redirects cache so they show up immediately
|
||||||
|
Redirects.warm_cache()
|
||||||
|
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("Dev seed data created:")
|
||||||
|
IO.puts(" 60 products (with variants + images)")
|
||||||
|
IO.puts(" 90 media images (60 product + 30 extra)")
|
||||||
|
IO.puts(" 60 orders")
|
||||||
|
IO.puts(" 60 newsletter subscribers")
|
||||||
|
IO.puts(" 30 newsletter campaigns")
|
||||||
|
IO.puts(" 30 redirects")
|
||||||
|
IO.puts(" 30 broken URLs")
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("All views should now show pagination controls.")
|
||||||
|
end
|
||||||
@ -84,7 +84,8 @@ defmodule Berrypod.Newsletter.NotifierTest do
|
|||||||
|
|
||||||
describe "deliver_test/2" do
|
describe "deliver_test/2" do
|
||||||
test "sends test email with [Test] prefix in subject" do
|
test "sends test email with [Test] prefix in subject" do
|
||||||
campaign = campaign_fixture(subject: "Big launch", body: "Preview this!\n\n{{unsubscribe_url}}")
|
campaign =
|
||||||
|
campaign_fixture(subject: "Big launch", body: "Preview this!\n\n{{unsubscribe_url}}")
|
||||||
|
|
||||||
assert {:ok, _} = Notifier.deliver_test(campaign, "admin@example.com")
|
assert {:ok, _} = Notifier.deliver_test(campaign, "admin@example.com")
|
||||||
|
|
||||||
|
|||||||
155
test/berrypod/pagination_test.exs
Normal file
155
test/berrypod/pagination_test.exs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
defmodule Berrypod.PaginationTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
|
||||||
|
alias Berrypod.Pagination
|
||||||
|
|
||||||
|
describe "page_numbers/1" do
|
||||||
|
test "returns all pages when total <= 7" do
|
||||||
|
assert Pagination.page_numbers(page(1, 1)) == [1]
|
||||||
|
assert Pagination.page_numbers(page(1, 5)) == [1, 2, 3, 4, 5]
|
||||||
|
assert Pagination.page_numbers(page(3, 7)) == [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows ellipsis for large page counts" do
|
||||||
|
result = Pagination.page_numbers(page(1, 20))
|
||||||
|
assert result == [1, 2, :ellipsis, 20]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows window around current page" do
|
||||||
|
result = Pagination.page_numbers(page(10, 20))
|
||||||
|
assert result == [1, :ellipsis, 9, 10, 11, :ellipsis, 20]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "current page near start" do
|
||||||
|
result = Pagination.page_numbers(page(2, 20))
|
||||||
|
assert result == [1, 2, 3, :ellipsis, 20]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "current page near end" do
|
||||||
|
result = Pagination.page_numbers(page(19, 20))
|
||||||
|
assert result == [1, :ellipsis, 18, 19, 20]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "current page at last" do
|
||||||
|
result = Pagination.page_numbers(page(20, 20))
|
||||||
|
assert result == [1, :ellipsis, 19, 20]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "8 pages with current at 4" do
|
||||||
|
result = Pagination.page_numbers(page(4, 8))
|
||||||
|
assert result == [1, :ellipsis, 3, 4, 5, :ellipsis, 8]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "showing_text/1" do
|
||||||
|
test "returns showing range" do
|
||||||
|
assert Pagination.showing_text(page(1, 5, 25, 120)) == "Showing 1\u201325 of 120"
|
||||||
|
assert Pagination.showing_text(page(2, 5, 25, 120)) == "Showing 26\u201350 of 120"
|
||||||
|
assert Pagination.showing_text(page(5, 5, 25, 120)) == "Showing 101\u2013120 of 120"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns no results when empty" do
|
||||||
|
assert Pagination.showing_text(page(1, 1, 25, 0)) == "No results"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "parse_page/1" do
|
||||||
|
test "returns 1 for nil" do
|
||||||
|
assert Pagination.parse_page(%{}) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses valid string" do
|
||||||
|
assert Pagination.parse_page(%{"page" => "3"}) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 1 for invalid string" do
|
||||||
|
assert Pagination.parse_page(%{"page" => "abc"}) == 1
|
||||||
|
assert Pagination.parse_page(%{"page" => "0"}) == 1
|
||||||
|
assert Pagination.parse_page(%{"page" => "-1"}) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts integer values" do
|
||||||
|
assert Pagination.parse_page(%{"page" => 5}) == 5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses custom key" do
|
||||||
|
assert Pagination.parse_page(%{"p" => "7"}, "p") == 7
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "paginate/2" do
|
||||||
|
test "paginates a query" do
|
||||||
|
# Create some subscribers as test data
|
||||||
|
for i <- 1..7 do
|
||||||
|
%Berrypod.Newsletter.Subscriber{}
|
||||||
|
|> Berrypod.Newsletter.Subscriber.changeset(%{
|
||||||
|
email: "pag#{i}@example.com",
|
||||||
|
status: "confirmed",
|
||||||
|
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||||
|
consent_text: "test"
|
||||||
|
})
|
||||||
|
|> Berrypod.Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
query =
|
||||||
|
from(s in Berrypod.Newsletter.Subscriber,
|
||||||
|
order_by: [asc: s.email]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page 1 of 3 (3 per page, 7 total)
|
||||||
|
result = Pagination.paginate(query, page: 1, per_page: 3)
|
||||||
|
assert length(result.items) == 3
|
||||||
|
assert result.page == 1
|
||||||
|
assert result.per_page == 3
|
||||||
|
assert result.total_count == 7
|
||||||
|
assert result.total_pages == 3
|
||||||
|
|
||||||
|
# Page 3 has 1 item
|
||||||
|
result = Pagination.paginate(query, page: 3, per_page: 3)
|
||||||
|
assert length(result.items) == 1
|
||||||
|
assert result.page == 3
|
||||||
|
|
||||||
|
# Page beyond range is clamped
|
||||||
|
result = Pagination.paginate(query, page: 99, per_page: 3)
|
||||||
|
assert result.page == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty result set" do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
query =
|
||||||
|
from(s in Berrypod.Newsletter.Subscriber,
|
||||||
|
where: s.email == "nonexistent@nope.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Pagination.paginate(query, page: 1, per_page: 25)
|
||||||
|
assert result.items == []
|
||||||
|
assert result.total_count == 0
|
||||||
|
assert result.total_pages == 1
|
||||||
|
assert result.page == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helpers for building test structs
|
||||||
|
defp page(current, total) do
|
||||||
|
%Pagination{
|
||||||
|
items: [],
|
||||||
|
page: current,
|
||||||
|
per_page: 25,
|
||||||
|
total_count: total * 25,
|
||||||
|
total_pages: total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp page(current, total_pages, per_page, total_count) do
|
||||||
|
%Pagination{
|
||||||
|
items: [],
|
||||||
|
page: current,
|
||||||
|
per_page: per_page,
|
||||||
|
total_count: total_count,
|
||||||
|
total_pages: total_pages
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user