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>
400 lines
13 KiB
Elixir
400 lines
13 KiB
Elixir
# 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
|