2026-02-18 21:23:15 +00:00
|
|
|
defmodule Berrypod.Products.Product do
|
2026-01-29 08:32:24 +00:00
|
|
|
@moduledoc """
|
|
|
|
|
Schema for products synced from POD providers.
|
|
|
|
|
|
|
|
|
|
Products are uniquely identified by the combination of
|
|
|
|
|
provider_connection_id and provider_product_id.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
use Ecto.Schema
|
|
|
|
|
import Ecto.Changeset
|
|
|
|
|
|
|
|
|
|
@primary_key {:id, :binary_id, autogenerate: true}
|
|
|
|
|
@foreign_key_type :binary_id
|
|
|
|
|
|
|
|
|
|
@statuses ~w(active draft archived)
|
|
|
|
|
|
|
|
|
|
schema "products" do
|
|
|
|
|
field :provider_product_id, :string
|
|
|
|
|
field :title, :string
|
|
|
|
|
field :description, :string
|
|
|
|
|
field :slug, :string
|
|
|
|
|
field :status, :string, default: "active"
|
|
|
|
|
field :visible, :boolean, default: true
|
|
|
|
|
field :category, :string
|
|
|
|
|
field :provider_data, :map, default: %{}
|
|
|
|
|
field :checksum, :string
|
|
|
|
|
|
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>
2026-02-13 01:26:39 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-02-18 21:23:15 +00:00
|
|
|
belongs_to :provider_connection, Berrypod.Products.ProviderConnection
|
|
|
|
|
has_many :images, Berrypod.Products.ProductImage
|
|
|
|
|
has_many :variants, Berrypod.Products.ProductVariant
|
2026-01-29 08:32:24 +00:00
|
|
|
|
|
|
|
|
timestamps(type: :utc_datetime)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Returns the list of valid product statuses.
|
|
|
|
|
"""
|
|
|
|
|
def statuses, do: @statuses
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Changeset for creating or updating a product.
|
|
|
|
|
"""
|
|
|
|
|
def changeset(product, attrs) do
|
|
|
|
|
product
|
|
|
|
|
|> cast(attrs, [
|
|
|
|
|
:provider_connection_id,
|
|
|
|
|
:provider_product_id,
|
|
|
|
|
:title,
|
|
|
|
|
:description,
|
|
|
|
|
:slug,
|
|
|
|
|
:status,
|
|
|
|
|
:visible,
|
|
|
|
|
:category,
|
|
|
|
|
:provider_data,
|
|
|
|
|
:checksum
|
|
|
|
|
])
|
|
|
|
|
|> generate_slug_if_missing()
|
|
|
|
|
|> validate_required([:provider_connection_id, :provider_product_id, :title, :slug])
|
|
|
|
|
|> validate_inclusion(:status, @statuses)
|
|
|
|
|
|> unique_constraint(:slug)
|
|
|
|
|
|> unique_constraint([:provider_connection_id, :provider_product_id])
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-16 08:48:51 +00:00
|
|
|
@doc """
|
|
|
|
|
Changeset for admin storefront controls (visibility and category only).
|
|
|
|
|
"""
|
|
|
|
|
def storefront_changeset(product, attrs) do
|
|
|
|
|
product
|
|
|
|
|
|> cast(attrs, [:visible, :category])
|
|
|
|
|
end
|
|
|
|
|
|
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>
2026-02-13 01:26:39 +00:00
|
|
|
@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.
|
2026-02-13 08:27:26 +00:00
|
|
|
Returns a list of %{name: "Size", type: :size, values: [%{title: "S"}, ...]}.
|
|
|
|
|
Color options include :hex from the provider's color data.
|
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>
2026-02-13 01:26:39 +00:00
|
|
|
"""
|
|
|
|
|
def option_types(%{provider_data: %{"options" => options}}) when is_list(options) do
|
|
|
|
|
Enum.map(options, fn opt ->
|
2026-02-13 08:27:26 +00:00
|
|
|
type = option_type_atom(opt["type"])
|
|
|
|
|
|
|
|
|
|
values =
|
|
|
|
|
Enum.map(opt["values"] || [], fn val ->
|
|
|
|
|
base = %{title: val["title"]}
|
|
|
|
|
|
|
|
|
|
case val["colors"] do
|
|
|
|
|
[hex | _] -> Map.put(base, :hex, hex)
|
|
|
|
|
_ -> base
|
|
|
|
|
end
|
|
|
|
|
end)
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
%{name: singularize_option_name(opt["name"]), type: type, values: values}
|
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>
2026-02-13 01:26:39 +00:00
|
|
|
end)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types
|
|
|
|
|
def option_types(_), do: []
|
|
|
|
|
|
2026-02-13 08:27:26 +00:00
|
|
|
defp option_type_atom("color"), do: :color
|
|
|
|
|
defp option_type_atom(_), do: :size
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
# Printify sends plural names ("Colors", "Sizes") but variant options
|
|
|
|
|
# use singular — keep them consistent so gallery filtering works.
|
|
|
|
|
defp singularize_option_name("Colors"), do: "Color"
|
|
|
|
|
defp singularize_option_name("Sizes"), do: "Size"
|
|
|
|
|
defp singularize_option_name(name), do: name
|
|
|
|
|
|
2026-01-29 08:32:24 +00:00
|
|
|
@doc """
|
|
|
|
|
Generates a checksum from provider data for detecting changes.
|
|
|
|
|
"""
|
|
|
|
|
def compute_checksum(provider_data) when is_map(provider_data) do
|
|
|
|
|
provider_data
|
|
|
|
|
|> Jason.encode!()
|
|
|
|
|
|> then(&:crypto.hash(:sha256, &1))
|
|
|
|
|
|> Base.encode16(case: :lower)
|
|
|
|
|
|> binary_part(0, 16)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def compute_checksum(_), do: nil
|
|
|
|
|
|
|
|
|
|
defp generate_slug_if_missing(changeset) do
|
2026-02-26 14:14:14 +00:00
|
|
|
slug_change = get_change(changeset, :slug)
|
|
|
|
|
title_change = get_change(changeset, :title)
|
|
|
|
|
current_slug = get_field(changeset, :slug)
|
|
|
|
|
|
|
|
|
|
cond do
|
|
|
|
|
# Explicit slug provided — use it as-is
|
|
|
|
|
slug_change != nil ->
|
|
|
|
|
changeset
|
|
|
|
|
|
|
|
|
|
# Title changed — regenerate slug to match
|
|
|
|
|
title_change != nil ->
|
|
|
|
|
put_change(changeset, :slug, Slug.slugify(title_change))
|
|
|
|
|
|
|
|
|
|
# No slug yet — generate from title
|
|
|
|
|
current_slug == nil ->
|
|
|
|
|
title = get_field(changeset, :title)
|
2026-01-29 08:32:24 +00:00
|
|
|
|
|
|
|
|
if title do
|
2026-01-31 22:08:34 +00:00
|
|
|
put_change(changeset, :slug, Slug.slugify(title))
|
2026-01-29 08:32:24 +00:00
|
|
|
else
|
|
|
|
|
changeset
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-26 14:14:14 +00:00
|
|
|
# Slug exists and title didn't change — keep it
|
|
|
|
|
true ->
|
2026-01-29 08:32:24 +00:00
|
|
|
changeset
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defmodule Slug do
|
|
|
|
|
@moduledoc false
|
|
|
|
|
|
|
|
|
|
def slugify(nil), do: nil
|
|
|
|
|
|
|
|
|
|
def slugify(string) when is_binary(string) do
|
|
|
|
|
string
|
|
|
|
|
|> String.downcase()
|
|
|
|
|
|> String.replace(~r/[^\w\s-]/, "")
|
|
|
|
|
|> String.replace(~r/\s+/, "-")
|
|
|
|
|
|> String.replace(~r/-+/, "-")
|
|
|
|
|
|> String.trim("-")
|
|
|
|
|
end
|
|
|
|
|
end
|