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:
parent
81520754ee
commit
c818d0399c
20
PROGRESS.md
20
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 |
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = [
|
||||
# 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"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user