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:
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
|
||||
Reference in New Issue
Block a user