add denormalized product fields and use Product structs throughout

Adds cheapest_price, compare_at_price, in_stock, on_sale columns to
products table (recomputed from variants after each sync). Shop
components now work with Product structs directly instead of plain
maps from PreviewData. Renames .name to .title, adds Product display
helpers (primary_image, hover_image, option_types) and ProductImage
helpers (display_url, direct_url, source_width). Adds Products context
query functions for storefront use (list_visible_products,
get_visible_product, list_categories with DB-level sort/filter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-13 01:26:39 +00:00
parent 0b4fe031b7
commit 35e0386abb
20 changed files with 1000 additions and 328 deletions

View File

@@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do
"""
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.{Product, ProductImage}
@session_key "cart"
@@ -179,17 +180,13 @@ defmodule SimpleshopTheme.Cart do
(product[:variants] || [])
|> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end)
|> Enum.map(fn v ->
image =
case product[:image_url] do
"/mockups/" <> _ = url -> "#{url}-400.webp"
url -> url
end
image = ProductImage.direct_url(Product.primary_image(product), 400)
{v.id,
%{
variant_id: v.id,
product_id: product.id,
name: product.name,
product_id: product[:id],
name: product.title,
variant: format_variant_options(v.options),
price: v.price,
quantity: 1,

View File

@@ -108,6 +108,115 @@ defmodule SimpleshopTheme.Products do
SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
end
# =============================================================================
# Storefront queries
# =============================================================================
# Listing pages only need images (price/stock are denormalized on product)
@listing_preloads [images: :image]
# Detail page also needs variants for the variant selector
@detail_preloads [images: :image, variants: []]
@doc """
Gets a single visible, active product by slug with full preloads (for detail page).
"""
def get_visible_product(slug) do
Product
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|> preload(^@detail_preloads)
|> Repo.one()
end
@doc """
Lists visible, active products with listing preloads (no variants).
## Options
* `:sort` - sort order: "price_asc", "price_desc", "newest", "name_asc", "name_desc"
* `:category` - filter by category name
* `:on_sale` - if true, only products on sale
* `:in_stock` - if true, only products in stock
* `:limit` - max number of results
* `:exclude` - product ID to exclude
"""
def list_visible_products(opts \\ []) do
Product
|> where([p], p.visible == true and p.status == "active")
|> apply_visible_filters(opts)
|> apply_sort(opts[:sort])
|> maybe_limit(opts[:limit])
|> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Repo.all()
end
@doc """
Lists distinct categories from visible, active products.
Returns a list of %{name: "Category", slug: "category"}.
"""
def list_categories do
from(p in Product,
where: p.visible == true and p.status == "active" and not is_nil(p.category),
select: p.category,
distinct: true,
order_by: p.category
)
|> Repo.all()
|> Enum.map(fn name -> %{name: name, slug: Slug.slugify(name)} end)
end
@doc """
Recomputes denormalized fields from a product's variants.
Called after variant sync to keep cached fields up to date.
"""
def recompute_cached_fields(%Product{} = product) do
variants = Repo.all(from v in ProductVariant, where: v.product_id == ^product.id)
available = Enum.filter(variants, &(&1.is_enabled and &1.is_available))
cheapest = Enum.min_by(available, & &1.price, fn -> nil end)
attrs = %{
cheapest_price: if(cheapest, do: cheapest.price, else: 0),
compare_at_price: if(cheapest, do: cheapest.compare_at_price),
in_stock: available != [],
on_sale: Enum.any?(variants, &ProductVariant.on_sale?/1)
}
product
|> Product.recompute_changeset(attrs)
|> Repo.update()
end
defp apply_visible_filters(query, opts) do
query
|> maybe_filter_category(opts[:category])
|> maybe_filter_on_sale(opts[:on_sale])
|> maybe_filter_in_stock(opts[:in_stock])
end
defp maybe_filter_category(query, nil), do: query
defp maybe_filter_category(query, name), do: where(query, [p], p.category == ^name)
defp maybe_filter_on_sale(query, true), do: where(query, [p], p.on_sale == true)
defp maybe_filter_on_sale(query, _), do: query
defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true)
defp maybe_filter_in_stock(query, _), do: query
defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price)
defp apply_sort(query, "price_desc"), do: order_by(query, [p], desc: p.cheapest_price)
defp apply_sort(query, "newest"), do: order_by(query, [p], desc: p.inserted_at)
defp apply_sort(query, "name_asc"), do: order_by(query, [p], asc: p.title)
defp apply_sort(query, "name_desc"), do: order_by(query, [p], desc: p.title)
defp apply_sort(query, _), do: order_by(query, [p], desc: p.inserted_at)
defp maybe_limit(query, nil), do: query
defp maybe_limit(query, n) when is_integer(n), do: limit(query, ^n)
defp maybe_exclude(query, nil), do: query
defp maybe_exclude(query, id), do: where(query, [p], p.id != ^id)
# =============================================================================
# Products
# =============================================================================

View File

@@ -25,6 +25,12 @@ defmodule SimpleshopTheme.Products.Product do
field :provider_data, :map, default: %{}
field :checksum, :string
# Denormalized from variants — recomputed by Products.recompute_cached_fields/1
field :cheapest_price, :integer, default: 0
field :compare_at_price, :integer
field :in_stock, :boolean, default: true
field :on_sale, :boolean, default: false
belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection
has_many :images, SimpleshopTheme.Products.ProductImage
has_many :variants, SimpleshopTheme.Products.ProductVariant
@@ -61,6 +67,55 @@ defmodule SimpleshopTheme.Products.Product do
|> unique_constraint([:provider_connection_id, :provider_product_id])
end
@doc """
Changeset for recomputing denormalized fields from variants.
"""
def recompute_changeset(product, attrs) do
product
|> cast(attrs, [:cheapest_price, :compare_at_price, :in_stock, :on_sale])
end
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
@doc """
Returns the primary (first by position) image, or nil.
Works with preloaded images association or plain maps.
"""
def primary_image(%{images: images}) when is_list(images) do
Enum.min_by(images, & &1.position, fn -> nil end)
end
def primary_image(_), do: nil
@doc """
Returns the second image by position (hover image), or nil.
"""
def hover_image(%{images: images}) when is_list(images) and length(images) >= 2 do
images
|> Enum.sort_by(& &1.position)
|> Enum.at(1)
end
def hover_image(_), do: nil
@doc """
Extracts option types from provider_data.
Returns a list of %{name: "Size", values: ["S", "M", "L"]}.
"""
def option_types(%{provider_data: %{"options" => options}}) when is_list(options) do
Enum.map(options, fn opt ->
%{
name: opt["name"],
values: Enum.map(opt["values"] || [], & &1["title"])
}
end)
end
def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types
def option_types(_), do: []
@doc """
Generates a checksum from provider data for detecting changes.
"""

View File

@@ -33,4 +33,40 @@ defmodule SimpleshopTheme.Products.ProductImage do
|> foreign_key_constraint(:product_id)
|> foreign_key_constraint(:image_id)
end
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
@doc """
Returns the display URL for a product image at the given size.
Prefers local image_id (responsive optimized), falls back to CDN src.
"""
def display_url(image, size \\ 800)
def display_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def display_url(%{src: src}, _size) when is_binary(src), do: src
def display_url(_, _), do: nil
@doc """
Returns a fully resolved URL for an image at the given size.
Unlike `display_url/2`, handles mockup URL patterns that need size suffixes.
Use for `<img>` tags where the URL must resolve to an actual file.
"""
def direct_url(image, size \\ 800)
def direct_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def direct_url(%{src: "/mockups/" <> _ = src}, size), do: "#{src}-#{size}.webp"
def direct_url(%{src: src}, _size) when is_binary(src), do: src
def direct_url(_, _), do: nil
@doc """
Returns the source width from the linked Media.Image, if preloaded.
"""
def source_width(%{image: %{source_width: w}}) when not is_nil(w), do: w
def source_width(_), do: nil
end

View File

@@ -192,5 +192,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
end)
Products.sync_product_variants(product, variants)
# Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants
Products.recompute_cached_fields(product)
end
end

View File

@@ -363,7 +363,6 @@ defmodule SimpleshopTheme.Theme.PreviewData do
status: "active",
preload: [images: :image, variants: []]
)
|> Enum.map(&product_to_map/1)
end
defp get_real_categories do
@@ -383,129 +382,30 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|> 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 and &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)
# Use local image if available, fall back to CDN URL
{image_url, image_id, source_width} = image_attrs(first_image)
{hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_image)
# Map variants to frontend format (only enabled/published ones)
variants =
product.variants
|> Enum.filter(& &1.is_enabled)
|> Enum.map(fn v ->
%{
id: v.id,
provider_variant_id: v.provider_variant_id,
title: v.title,
price: v.price,
compare_at_price: v.compare_at_price,
options: v.options,
is_available: v.is_available
}
end)
# Extract option types, filtered to only values present in enabled variants
option_types =
SimpleshopTheme.Providers.Printify.extract_option_types(product.provider_data)
|> filter_option_types_by_variants(variants)
%{
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: image_url,
image_id: image_id,
hover_image_url: hover_image_url,
hover_image_id: hover_image_id,
source_width: source_width,
hover_source_width: hover_source_width,
category: product.category,
slug: product.slug,
in_stock: in_stock,
on_sale: on_sale,
inserted_at: product.inserted_at,
option_types: option_types,
variants: variants
}
end
# Extract image attributes, preferring local Media.Image when available
defp image_attrs(nil), do: {nil, nil, nil}
defp image_attrs(%{image_id: image_id, image: %{source_width: source_width}})
when not is_nil(image_id) do
# Local image available - use image_id for responsive <picture> element
{nil, image_id, source_width}
end
defp image_attrs(%{src: src}) do
# Fall back to CDN URL
{src, nil, nil}
end
# Filter option types to only include values present in enabled variants
# This ensures we don't show unpublished options from the Printify catalog
defp filter_option_types_by_variants(option_types, variants) do
# Collect all option values present in the enabled variants
values_in_use =
Enum.reduce(variants, %{}, fn variant, acc ->
Enum.reduce(variant.options, acc, fn {opt_name, opt_value}, inner_acc ->
Map.update(inner_acc, opt_name, MapSet.new([opt_value]), &MapSet.put(&1, opt_value))
end)
end)
# Filter each option type's values to only those in use
option_types
|> Enum.map(fn opt_type ->
used_values = Map.get(values_in_use, opt_type.name, MapSet.new())
filtered_values =
opt_type.values
|> Enum.filter(fn v -> MapSet.member?(used_values, v.title) end)
%{opt_type | values: filtered_values}
end)
|> Enum.reject(fn opt_type -> opt_type.values == [] end)
end
# Default source width for mockup variants (max generated size)
@mockup_source_width 1200
defp mock_products do
[
# Art Prints
%{
id: "1",
name: "Mountain Sunrise Art Print",
slug: "1",
title: "Mountain Sunrise Art Print",
description: "Capture the magic of dawn with this stunning mountain landscape print",
price: 1999,
cheapest_price: 1999,
compare_at_price: nil,
image_url: "/mockups/mountain-sunrise-print-1",
hover_image_url: "/mockups/mountain-sunrise-print-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/mountain-sunrise-print-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/mountain-sunrise-print-2",
image: %{source_width: 1200}
}
],
category: "Art Prints",
in_stock: true,
on_sale: false,
@@ -546,14 +446,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "2",
name: "Ocean Waves Art Print",
slug: "2",
title: "Ocean Waves Art Print",
description: "A calming sunset over ocean waves to bring peace to any room",
price: 2400,
cheapest_price: 2400,
compare_at_price: nil,
image_url: "/mockups/ocean-waves-print-1",
hover_image_url: "/mockups/ocean-waves-print-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/ocean-waves-print-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/ocean-waves-print-2",
image: %{source_width: 1200}
}
],
category: "Art Prints",
in_stock: true,
on_sale: false,
@@ -572,14 +483,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "3",
name: "Wildflower Meadow Art Print",
slug: "3",
title: "Wildflower Meadow Art Print",
description: "Beautiful wildflower meadow captured in the summer sunshine",
price: 2400,
cheapest_price: 2400,
compare_at_price: nil,
image_url: "/mockups/wildflower-meadow-print-1",
hover_image_url: "/mockups/wildflower-meadow-print-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/wildflower-meadow-print-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/wildflower-meadow-print-2",
image: %{source_width: 1200}
}
],
category: "Art Prints",
in_stock: true,
on_sale: false,
@@ -598,14 +520,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "4",
name: "Geometric Abstract Art Print",
slug: "4",
title: "Geometric Abstract Art Print",
description: "Modern minimalist design with bold geometric shapes",
price: 2800,
cheapest_price: 2800,
compare_at_price: 3200,
image_url: "/mockups/geometric-abstract-print-1",
hover_image_url: "/mockups/geometric-abstract-print-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/geometric-abstract-print-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/geometric-abstract-print-2",
image: %{source_width: 1200}
}
],
category: "Art Prints",
in_stock: true,
on_sale: true,
@@ -625,14 +558,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "5",
name: "Botanical Illustration Print",
slug: "5",
title: "Botanical Illustration Print",
description: "Vintage-inspired botanical drawing with intricate detail",
price: 2400,
cheapest_price: 2400,
compare_at_price: nil,
image_url: "/mockups/botanical-illustration-print-1",
hover_image_url: "/mockups/botanical-illustration-print-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/botanical-illustration-print-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/botanical-illustration-print-2",
image: %{source_width: 1200}
}
],
category: "Art Prints",
in_stock: true,
on_sale: false,
@@ -652,14 +596,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Apparel
%{
id: "6",
name: "Forest Silhouette T-Shirt",
slug: "6",
title: "Forest Silhouette T-Shirt",
description: "Soft cotton tee featuring a peaceful forest silhouette design",
price: 2999,
cheapest_price: 2999,
compare_at_price: nil,
image_url: "/mockups/forest-silhouette-tshirt-1",
hover_image_url: "/mockups/forest-silhouette-tshirt-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/forest-silhouette-tshirt-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/forest-silhouette-tshirt-2",
image: %{source_width: 1200}
}
],
category: "Apparel",
in_stock: true,
on_sale: false,
@@ -835,14 +790,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "7",
name: "Forest Light Hoodie",
slug: "7",
title: "Forest Light Hoodie",
description: "Cosy fleece hoodie with stunning forest light photography",
price: 4499,
cheapest_price: 4499,
compare_at_price: 4999,
image_url: "/mockups/forest-light-hoodie-1",
hover_image_url: "/mockups/forest-light-hoodie-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/forest-light-hoodie-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/forest-light-hoodie-2",
image: %{source_width: 1200}
}
],
category: "Apparel",
in_stock: true,
on_sale: true,
@@ -882,14 +848,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "8",
name: "Wildflower Meadow Tote Bag",
slug: "8",
title: "Wildflower Meadow Tote Bag",
description: "Sturdy cotton tote bag with vibrant wildflower design",
price: 1999,
cheapest_price: 1999,
compare_at_price: nil,
image_url: "/mockups/wildflower-meadow-tote-1",
hover_image_url: "/mockups/wildflower-meadow-tote-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/wildflower-meadow-tote-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/wildflower-meadow-tote-2",
image: %{source_width: 1200}
}
],
category: "Apparel",
in_stock: true,
on_sale: false,
@@ -899,14 +876,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "9",
name: "Sunset Gradient Tote Bag",
slug: "9",
title: "Sunset Gradient Tote Bag",
description: "Beautiful ocean sunset printed on durable canvas tote",
price: 1999,
cheapest_price: 1999,
compare_at_price: nil,
image_url: "/mockups/sunset-gradient-tote-1",
hover_image_url: "/mockups/sunset-gradient-tote-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/sunset-gradient-tote-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/sunset-gradient-tote-2",
image: %{source_width: 1200}
}
],
category: "Apparel",
in_stock: true,
on_sale: false,
@@ -917,14 +905,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Homewares
%{
id: "10",
name: "Fern Leaf Mug",
slug: "10",
title: "Fern Leaf Mug",
description: "Start your morning right with this nature-inspired ceramic mug",
price: 1499,
cheapest_price: 1499,
compare_at_price: nil,
image_url: "/mockups/fern-leaf-mug-1",
hover_image_url: "/mockups/fern-leaf-mug-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/fern-leaf-mug-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/fern-leaf-mug-2",
image: %{source_width: 1200}
}
],
category: "Homewares",
in_stock: true,
on_sale: false,
@@ -950,14 +949,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "11",
name: "Ocean Waves Cushion",
slug: "11",
title: "Ocean Waves Cushion",
description: "Soft polyester cushion featuring a stunning ocean sunset",
price: 2999,
cheapest_price: 2999,
compare_at_price: nil,
image_url: "/mockups/ocean-waves-cushion-1",
hover_image_url: "/mockups/ocean-waves-cushion-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/ocean-waves-cushion-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/ocean-waves-cushion-2",
image: %{source_width: 1200}
}
],
category: "Homewares",
in_stock: true,
on_sale: false,
@@ -967,14 +977,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "12",
name: "Night Sky Blanket",
slug: "12",
title: "Night Sky Blanket",
description: "Cosy sherpa fleece blanket with mesmerising milky way print",
price: 5999,
cheapest_price: 5999,
compare_at_price: 6999,
image_url: "/mockups/night-sky-blanket-1",
hover_image_url: "/mockups/night-sky-blanket-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/night-sky-blanket-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/night-sky-blanket-2",
image: %{source_width: 1200}
}
],
category: "Homewares",
in_stock: true,
on_sale: true,
@@ -992,14 +1013,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Stationery
%{
id: "13",
name: "Autumn Leaves Notebook",
slug: "13",
title: "Autumn Leaves Notebook",
description: "Hardcover journal with beautiful autumn foliage design",
price: 1999,
cheapest_price: 1999,
compare_at_price: nil,
image_url: "/mockups/autumn-leaves-notebook-1",
hover_image_url: "/mockups/autumn-leaves-notebook-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/autumn-leaves-notebook-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/autumn-leaves-notebook-2",
image: %{source_width: 1200}
}
],
category: "Stationery",
in_stock: true,
on_sale: false,
@@ -1009,14 +1041,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "14",
name: "Monstera Leaf Notebook",
slug: "14",
title: "Monstera Leaf Notebook",
description: "Tropical-inspired hardcover journal for your thoughts",
price: 1999,
cheapest_price: 1999,
compare_at_price: nil,
image_url: "/mockups/monstera-leaf-notebook-1",
hover_image_url: "/mockups/monstera-leaf-notebook-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/monstera-leaf-notebook-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/monstera-leaf-notebook-2",
image: %{source_width: 1200}
}
],
category: "Stationery",
in_stock: true,
on_sale: false,
@@ -1027,14 +1070,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Accessories
%{
id: "15",
name: "Monstera Leaf Phone Case",
slug: "15",
title: "Monstera Leaf Phone Case",
description: "Tough phone case with stunning monstera leaf photography",
price: 2499,
cheapest_price: 2499,
compare_at_price: nil,
image_url: "/mockups/monstera-leaf-phone-case-1",
hover_image_url: "/mockups/monstera-leaf-phone-case-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/monstera-leaf-phone-case-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/monstera-leaf-phone-case-2",
image: %{source_width: 1200}
}
],
category: "Accessories",
in_stock: true,
on_sale: false,
@@ -1044,14 +1098,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
},
%{
id: "16",
name: "Blue Waves Laptop Sleeve",
slug: "16",
title: "Blue Waves Laptop Sleeve",
description: "Protective laptop sleeve with abstract blue gradient design",
price: 3499,
cheapest_price: 3499,
compare_at_price: nil,
image_url: "/mockups/blue-waves-laptop-sleeve-1",
hover_image_url: "/mockups/blue-waves-laptop-sleeve-2",
source_width: @mockup_source_width,
hover_source_width: @mockup_source_width,
images: [
%{
position: 0,
image_id: nil,
src: "/mockups/blue-waves-laptop-sleeve-1",
image: %{source_width: 1200}
},
%{
position: 1,
image_id: nil,
src: "/mockups/blue-waves-laptop-sleeve-2",
image: %{source_width: 1200}
}
],
category: "Accessories",
in_stock: true,
on_sale: false,