From c818d0399c3a937afe4c2debd89ace3af045210c Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 31 Jan 2026 23:07:37 +0000 Subject: [PATCH] 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 --- PROGRESS.md | 20 ++++-- lib/simpleshop_theme/providers/printify.ex | 29 +++++--- lib/simpleshop_theme/theme/preview_data.ex | 71 +++++++++++++++++-- .../live/shop_live/product_show.ex | 30 +++++--- 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 0b6002d..d471bd7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 | diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex index ec06762..843ab9f 100644 --- a/lib/simpleshop_theme/providers/printify.ex +++ b/lib/simpleshop_theme/providers/printify.ex @@ -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"]), diff --git a/lib/simpleshop_theme/theme/preview_data.ex b/lib/simpleshop_theme/theme/preview_data.ex index 7139f16..8b87ff7 100644 --- a/lib/simpleshop_theme/theme/preview_data.ex +++ b/lib/simpleshop_theme/theme/preview_data.ex @@ -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) diff --git a/lib/simpleshop_theme_web/live/shop_live/product_show.ex b/lib/simpleshop_theme_web/live/shop_live/product_show.ex index b42a623..890fe2c 100644 --- a/lib/simpleshop_theme_web/live/shop_live/product_show.ex +++ b/lib/simpleshop_theme_web/live/shop_live/product_show.ex @@ -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"""