Packages

phoenix_kit

1.7.55
1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules shop schemas product.ex
Raw

lib/modules/shop/schemas/product.ex

defmodule PhoenixKit.Modules.Shop.Product do
@moduledoc """
Product schema for e-commerce shop.
Supports both physical and digital products with JSONB flexibility.
## Fields
- `title` - Product title (required)
- `slug` - URL-friendly identifier (unique)
- `description` - Short description
- `body_html` - Full rich text description
- `status` - draft | active | archived
- `product_type` - physical | digital
- `vendor` - Brand/manufacturer
- `tags` - JSONB array of tags
- `price` - Base price (required)
- `compare_at_price` - Original price for discounts
- `cost_per_item` - Cost for profit calculation
- `currency` - ISO currency code (default: USD)
- `taxable` - Subject to tax
- `weight_grams` - Weight for shipping
- `requires_shipping` - Needs physical delivery
- `made_to_order` - Always available regardless of inventory
- `images` - JSONB array of image objects
- `featured_image` - Main image URL
- `seo_title` - SEO title
- `seo_description` - SEO description
- `file_uuid` - Storage file reference (digital products)
- `download_limit` - Max downloads (digital)
- `download_expiry_days` - Days until download expires
- `metadata` - JSONB for custom fields
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
@statuses ["draft", "active", "archived"]
@product_types ["physical", "digital"]
@primary_key {:uuid, UUIDv7, autogenerate: true}
schema "phoenix_kit_shop_products" do
# Localized fields (JSONB maps: %{"en" => "value", "ru" => "значение"})
field :title, :map, default: %{}
field :slug, :map, default: %{}
field :description, :map, default: %{}
field :body_html, :map, default: %{}
# Status (non-localized)
field :status, :string, default: "draft"
# Type
field :product_type, :string, default: "physical"
field :vendor, :string
field :tags, {:array, :string}, default: []
# Pricing
field :price, :decimal
field :compare_at_price, :decimal
field :cost_per_item, :decimal
field :currency, :string, default: "USD"
field :taxable, :boolean, default: true
# Physical properties
field :weight_grams, :integer, default: 0
field :requires_shipping, :boolean, default: true
# Availability
field :made_to_order, :boolean, default: false
# Media (legacy URL-based)
field :images, {:array, :map}, default: []
field :featured_image, :string
# Media (Storage integration)
field :featured_image_uuid, Ecto.UUID
field :image_ids, {:array, Ecto.UUID}, default: []
# SEO (localized JSONB maps)
field :seo_title, :map, default: %{}
field :seo_description, :map, default: %{}
# Digital products
field :file_uuid, Ecto.UUID
field :download_limit, :integer
field :download_expiry_days, :integer
# Extensibility
field :metadata, :map, default: %{}
# Relations
belongs_to :category, PhoenixKit.Modules.Shop.Category,
foreign_key: :category_uuid,
references: :uuid,
type: UUIDv7
belongs_to :created_by_user, PhoenixKit.Users.Auth.User,
foreign_key: :created_by_uuid,
references: :uuid,
type: UUIDv7
timestamps(type: :utc_datetime)
end
@doc """
Changeset for product creation and updates.
"""
@localized_fields [:title, :slug, :description, :body_html, :seo_title, :seo_description]
def changeset(product, attrs) do
product
|> cast(attrs, [
:title,
:slug,
:description,
:body_html,
:status,
:product_type,
:vendor,
:tags,
:price,
:compare_at_price,
:cost_per_item,
:currency,
:taxable,
:weight_grams,
:requires_shipping,
:made_to_order,
:images,
:featured_image,
:featured_image_uuid,
:image_ids,
:seo_title,
:seo_description,
:file_uuid,
:download_limit,
:download_expiry_days,
:metadata,
:category_uuid,
:created_by_uuid
])
|> normalize_map_fields(@localized_fields)
|> validate_required([:price])
|> validate_localized_required(:title)
|> validate_inclusion(:status, @statuses)
|> validate_inclusion(:product_type, @product_types)
|> validate_number(:price, greater_than_or_equal_to: 0)
|> validate_number(:compare_at_price, greater_than_or_equal_to: 0)
|> validate_number(:cost_per_item, greater_than_or_equal_to: 0)
|> validate_number(:weight_grams, greater_than_or_equal_to: 0)
|> validate_number(:download_limit, greater_than: 0)
|> validate_number(:download_expiry_days, greater_than: 0)
|> validate_length(:currency, is: 3)
|> maybe_generate_slug()
end
@doc """
Returns the list of localized field names.
"""
def localized_fields, do: @localized_fields
@doc """
Returns true if product is active.
"""
def active?(%__MODULE__{status: "active"}), do: true
def active?(_), do: false
@doc """
Returns true if product is physical.
"""
def physical?(%__MODULE__{product_type: "physical"}), do: true
def physical?(_), do: false
@doc """
Returns true if product is digital.
"""
def digital?(%__MODULE__{product_type: "digital"}), do: true
def digital?(_), do: false
@doc """
Returns true if product requires shipping.
"""
def requires_shipping?(%__MODULE__{product_type: "digital"}), do: false
def requires_shipping?(%__MODULE__{requires_shipping: requires}), do: requires
@doc """
Returns the display price (compare_at_price if set, otherwise price).
"""
def display_price(%__MODULE__{compare_at_price: nil, price: price}), do: price
def display_price(%__MODULE__{compare_at_price: compare}), do: compare
@doc """
Returns true if product has a discount (compare_at_price > price).
"""
def on_sale?(%__MODULE__{compare_at_price: nil}), do: false
def on_sale?(%__MODULE__{compare_at_price: compare, price: price}) do
Decimal.compare(compare, price) == :gt
end
@doc """
Calculates discount percentage.
"""
def discount_percentage(%__MODULE__{} = product) do
if on_sale?(product) do
diff = Decimal.sub(product.compare_at_price, product.price)
percentage = Decimal.div(diff, product.compare_at_price)
Decimal.mult(percentage, 100) |> Decimal.round(0) |> Decimal.to_integer()
else
0
end
end
# Remove empty string values from map fields
defp normalize_map_fields(changeset, fields) do
Enum.reduce(fields, changeset, fn field, acc ->
case get_change(acc, field) do
nil ->
acc
map when is_map(map) ->
cleaned =
map
|> Enum.reject(fn {_k, v} -> v in [nil, ""] end)
|> Map.new()
put_change(acc, field, cleaned)
_ ->
acc
end
end)
end
# Validate that localized field has value for default language
defp validate_localized_required(changeset, field) do
value = get_field(changeset, field) || %{}
default_lang = default_language()
if Map.get(value, default_lang) in [nil, ""] do
add_error(changeset, field, "#{default_lang} translation is required")
else
changeset
end
end
# Generate slug from title for each language
defp maybe_generate_slug(changeset) do
title_map = get_field(changeset, :title) || %{}
slug_map = get_field(changeset, :slug) || %{}
# For each language with a title but no slug, generate one
updated_slugs =
Enum.reduce(title_map, slug_map, fn {lang, title}, acc ->
if Map.get(acc, lang) in [nil, ""] and title not in [nil, ""] do
generated = slugify(title)
Map.put(acc, lang, generated)
else
acc
end
end)
if updated_slugs != slug_map do
put_change(changeset, :slug, updated_slugs)
else
changeset
end
end
defp slugify(text) when is_binary(text) do
text
|> String.downcase()
|> String.replace(~r/[^\w\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
end
defp slugify(_), do: ""
defp default_language do
alias PhoenixKit.Modules.Languages
if Code.ensure_loaded?(Languages) and function_exported?(Languages, :enabled?, 0) and
Languages.enabled?() do
case Languages.get_default_language() do
%{code: code} -> code
_ -> "en"
end
else
"en"
end
end
end