feat: wire shop LiveViews to real product data

PreviewData now queries the Products context when real products exist,
falling back to mock data otherwise. Shop pages automatically display
synced Printify products.

Fixes:
- Printify image position was string ("front"), now uses index
- Category extraction improved to match more Printify tags
- ProductShow finds products by slug for real data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-01-31 23:07:37 +00:00
parent 81520754ee
commit c818d0399c
4 changed files with 118 additions and 32 deletions

View File

@ -12,13 +12,13 @@
- 100% PageSpeed score
**In Progress:**
- Products context with provider integration (sync working, wiring to shop views next)
- Products context with provider integration (wired to shop views, variant selector next)
## Next Up
1. **Wire Products to Shop LiveViews** - Replace PreviewData with real synced products
2. **Variant Selector Component** - Size/colour picker on product pages
3. **Session-based Cart** - Real cart with actual variants
1. **Variant Selector Component** - Size/colour picker on product pages
2. **Session-based Cart** - Real cart with actual variants
3. **Stripe Checkout Integration** - Payment processing
---
@ -57,12 +57,18 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
- [x] Admin Provider Setup UI (`/admin/providers`) - connect, test, sync
- [x] ProductSyncWorker with pagination, parallel processing, error recovery
- [x] Slug-based fallback matching for changed provider IDs
- [x] Printify webhook endpoint for real-time product updates (a9c15ea)
- [x] Printify webhook endpoint with HMAC verification (a9c15ea)
- Note: Printify only supports `product:deleted` and `product:publish:*` events (no `product:updated`)
#### Remaining Tasks
- [ ] Wire shop LiveViews to Products context (~2hr)
- [ ] Add variant selector component (~2hr)
#### Recently Completed
- [x] Wire shop LiveViews to Products context
- PreviewData now uses real products when available
- Fixed Printify image sync (position was string, not integer)
- Improved category extraction from Printify tags
#### Future Enhancements (post-MVP)
- [ ] Pre-checkout variant validation (verify availability before order)
- [ ] Cost change monitoring/alerts (warn if Printify cost increased)
@ -109,7 +115,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|---------|--------|-------|
| Products context Phase 1 | c5c06d9 | Schemas, provider abstraction |
| Admin provider setup UI | 5b736b9 | Connect, test, sync with pagination |
| Printify webhooks | a9c15ea | Real-time product updates |
| Printify webhooks | a9c15ea | Deletion + publish events (no update event available) |
| Oban Lifeline plugin | c1e1988 | Rescue orphaned jobs |
| Image optimization | Multiple | Full pipeline complete |
| Self-hosted fonts | - | 10 typefaces, 728KB |

View File

@ -200,10 +200,12 @@ defmodule SimpleshopTheme.Providers.Printify do
images
|> Enum.with_index()
|> Enum.map(fn {img, index} ->
# Printify returns position as a label string (e.g., "front", "back")
# We use the index as the numeric position instead
%{
src: img["src"],
position: img["position"] || index,
alt: nil
position: index,
alt: img["position"]
}
end)
end
@ -243,18 +245,27 @@ defmodule SimpleshopTheme.Providers.Printify do
end
defp extract_category(raw) do
# Try to extract category from tags
tags = raw["tags"] || []
# Try to extract category from tags (case-insensitive)
tags =
(raw["tags"] || [])
|> Enum.map(&String.downcase/1)
cond do
"apparel" in tags or "clothing" in tags -> "Apparel"
"homeware" in tags or "home" in tags -> "Homewares"
"accessories" in tags -> "Accessories"
"art" in tags or "print" in tags -> "Art Prints"
true -> nil
has_tag?(tags, ~w[t-shirt tshirt shirt hoodie sweatshirt apparel clothing]) -> "Apparel"
has_tag?(tags, ~w[mug cup blanket pillow cushion homeware homewares home]) -> "Homewares"
has_tag?(tags, ~w[notebook journal stationery]) -> "Stationery"
has_tag?(tags, ~w[phone case bag tote accessories]) -> "Accessories"
has_tag?(tags, ~w[art print poster canvas wall]) -> "Art Prints"
true -> List.first(raw["tags"])
end
end
defp has_tag?(tags, keywords) do
Enum.any?(tags, fn tag ->
Enum.any?(keywords, fn keyword -> String.contains?(tag, keyword) end)
end)
end
defp normalize_order_status(raw) do
%{
status: map_order_status(raw["status"]),

View File

@ -6,6 +6,8 @@ defmodule SimpleshopTheme.Theme.PreviewData do
This allows users to preview themes before adding products to their shop.
"""
alias SimpleshopTheme.Products
@doc """
Returns products for preview.
@ -167,22 +169,81 @@ defmodule SimpleshopTheme.Theme.PreviewData do
@doc """
Checks if the shop has real products.
Returns true if at least one product exists in the database.
Returns true if at least one visible, active product exists in the database.
Returns false if the database is unavailable (e.g., in tests without sandbox).
"""
def has_real_products? do
false
try do
Products.list_products(visible: true, status: "active") |> Enum.any?()
rescue
DBConnection.OwnershipError -> false
end
end
defp has_real_categories? do
false
has_real_products?()
end
defp get_real_products do
[]
Products.list_products(visible: true, status: "active", preload: [:images, :variants])
|> Enum.map(&product_to_map/1)
end
defp get_real_categories do
[]
Products.list_products(visible: true, status: "active")
|> Enum.map(& &1.category)
|> Enum.reject(&is_nil/1)
|> Enum.frequencies()
|> Enum.map(fn {name, count} ->
%{
id: Slug.slugify(name),
name: name,
slug: Slug.slugify(name),
product_count: count,
image_url: nil
}
end)
|> Enum.sort_by(& &1.name)
end
# Transform a Product struct to the map format expected by shop components
defp product_to_map(product) do
# Get images sorted by position
images = Enum.sort_by(product.images, & &1.position)
first_image = List.first(images)
second_image = Enum.at(images, 1)
# Get available variants for pricing
available_variants =
product.variants
|> Enum.filter(& &1.is_enabled)
|> Enum.filter(& &1.is_available)
# Get the cheapest available variant for display price
cheapest_variant =
available_variants
|> Enum.min_by(& &1.price, fn -> nil end)
# Determine stock and sale status
in_stock = Enum.any?(available_variants)
on_sale = Enum.any?(product.variants, &SimpleshopTheme.Products.ProductVariant.on_sale?/1)
%{
id: product.slug,
name: product.title,
description: product.description,
price: if(cheapest_variant, do: cheapest_variant.price, else: 0),
compare_at_price: if(cheapest_variant, do: cheapest_variant.compare_at_price, else: nil),
image_url: if(first_image, do: first_image.src, else: nil),
hover_image_url: if(second_image, do: second_image.src, else: nil),
source_width: nil,
hover_source_width: nil,
category: product.category,
slug: product.slug,
in_stock: in_stock,
on_sale: on_sale,
inserted_at: product.inserted_at
}
end
# Default source width for mockup variants (max generated size)

View File

@ -25,23 +25,24 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
products = PreviewData.products()
# Find product by ID (preview data uses integer IDs)
product_id = String.to_integer(id)
product = Enum.find(products, List.first(products), fn p -> p.id == product_id end)
# Find product by slug or ID (real products use slugs, mock data uses string IDs)
product = find_product(products, id)
# Get related products (exclude current product, take 4)
related_products =
products
|> Enum.reject(fn p -> p.id == product_id end)
|> Enum.reject(fn p -> p.id == product.id end)
|> Enum.take(4)
# Build gallery images
gallery_images = [
product.image_url,
product.hover_image_url,
product.image_url,
product.hover_image_url
]
# Build gallery images (filter out nils)
gallery_images =
[
product.image_url,
product.hover_image_url,
product.image_url,
product.hover_image_url
]
|> Enum.reject(&is_nil/1)
socket =
socket
@ -62,6 +63,13 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
{:ok, socket}
end
# Find product by slug first (real products), then try ID match (mock data)
defp find_product(products, id) do
Enum.find(products, fn p -> p[:slug] == id end) ||
Enum.find(products, fn p -> p.id == id end) ||
List.first(products)
end
@impl true
def render(assigns) do
~H"""