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"""