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 - 100% PageSpeed score
**In Progress:** **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 ## Next Up
1. **Wire Products to Shop LiveViews** - Replace PreviewData with real synced products 1. **Variant Selector Component** - Size/colour picker on product pages
2. **Variant Selector Component** - Size/colour picker on product pages 2. **Session-based Cart** - Real cart with actual variants
3. **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] Admin Provider Setup UI (`/admin/providers`) - connect, test, sync
- [x] ProductSyncWorker with pagination, parallel processing, error recovery - [x] ProductSyncWorker with pagination, parallel processing, error recovery
- [x] Slug-based fallback matching for changed provider IDs - [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 #### Remaining Tasks
- [ ] Wire shop LiveViews to Products context (~2hr)
- [ ] Add variant selector component (~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) #### Future Enhancements (post-MVP)
- [ ] Pre-checkout variant validation (verify availability before order) - [ ] Pre-checkout variant validation (verify availability before order)
- [ ] Cost change monitoring/alerts (warn if Printify cost increased) - [ ] 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 | | Products context Phase 1 | c5c06d9 | Schemas, provider abstraction |
| Admin provider setup UI | 5b736b9 | Connect, test, sync with pagination | | 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 | | Oban Lifeline plugin | c1e1988 | Rescue orphaned jobs |
| Image optimization | Multiple | Full pipeline complete | | Image optimization | Multiple | Full pipeline complete |
| Self-hosted fonts | - | 10 typefaces, 728KB | | Self-hosted fonts | - | 10 typefaces, 728KB |

View File

@ -200,10 +200,12 @@ defmodule SimpleshopTheme.Providers.Printify do
images images
|> Enum.with_index() |> Enum.with_index()
|> Enum.map(fn {img, 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"], src: img["src"],
position: img["position"] || index, position: index,
alt: nil alt: img["position"]
} }
end) end)
end end
@ -243,18 +245,27 @@ defmodule SimpleshopTheme.Providers.Printify do
end end
defp extract_category(raw) do defp extract_category(raw) do
# Try to extract category from tags # Try to extract category from tags (case-insensitive)
tags = raw["tags"] || [] tags =
(raw["tags"] || [])
|> Enum.map(&String.downcase/1)
cond do cond do
"apparel" in tags or "clothing" in tags -> "Apparel" has_tag?(tags, ~w[t-shirt tshirt shirt hoodie sweatshirt apparel clothing]) -> "Apparel"
"homeware" in tags or "home" in tags -> "Homewares" has_tag?(tags, ~w[mug cup blanket pillow cushion homeware homewares home]) -> "Homewares"
"accessories" in tags -> "Accessories" has_tag?(tags, ~w[notebook journal stationery]) -> "Stationery"
"art" in tags or "print" in tags -> "Art Prints" has_tag?(tags, ~w[phone case bag tote accessories]) -> "Accessories"
true -> nil has_tag?(tags, ~w[art print poster canvas wall]) -> "Art Prints"
true -> List.first(raw["tags"])
end end
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 defp normalize_order_status(raw) do
%{ %{
status: map_order_status(raw["status"]), 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. This allows users to preview themes before adding products to their shop.
""" """
alias SimpleshopTheme.Products
@doc """ @doc """
Returns products for preview. Returns products for preview.
@ -167,22 +169,81 @@ defmodule SimpleshopTheme.Theme.PreviewData do
@doc """ @doc """
Checks if the shop has real products. 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 def has_real_products? do
false try do
Products.list_products(visible: true, status: "active") |> Enum.any?()
rescue
DBConnection.OwnershipError -> false
end
end end
defp has_real_categories? do defp has_real_categories? do
false has_real_products?()
end end
defp get_real_products do defp get_real_products do
[] Products.list_products(visible: true, status: "active", preload: [:images, :variants])
|> Enum.map(&product_to_map/1)
end end
defp get_real_categories do 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 end
# Default source width for mockup variants (max generated size) # Default source width for mockup variants (max generated size)

View File

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