Packages
phoenix_kit
1.7.26
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
Current section
Files
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
- `has_variants` - Has product variants
- `option_names` - JSONB array of variant option names
- `images` - JSONB array of image objects
- `featured_image` - Main image URL
- `seo_title` - SEO title
- `seo_description` - SEO description
- `file_id` - 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
@statuses ["draft", "active", "archived"]
@product_types ["physical", "digital"]
schema "phoenix_kit_shop_products" do
field :uuid, Ecto.UUID
# Basic info
field :title, :string
field :slug, :string
field :description, :string
field :body_html, :string
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
# Variants
field :has_variants, :boolean, default: false
field :option_names, {:array, :string}, default: []
# Media
field :images, {:array, :map}, default: []
field :featured_image, :string
# SEO
field :seo_title, :string
field :seo_description, :string
# Digital products
field :file_id, Ecto.UUID
field :download_limit, :integer
field :download_expiry_days, :integer
# Extensibility
field :metadata, :map, default: %{}
# Relations
belongs_to :category, PhoenixKit.Modules.Shop.Category
belongs_to :created_by_user, PhoenixKit.Users.Auth.User, foreign_key: :created_by
timestamps()
end
@doc """
Changeset for product creation and updates.
"""
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,
:has_variants,
:option_names,
:images,
:featured_image,
:seo_title,
:seo_description,
:file_id,
:download_limit,
:download_expiry_days,
:metadata,
:category_id,
:created_by
])
|> validate_required([:title, :price])
|> 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(:title, max: 255)
|> validate_length(:slug, max: 255)
|> validate_length(:currency, is: 3)
|> maybe_generate_slug()
|> unique_constraint(:slug)
end
@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
# Generate slug from title if not provided
defp maybe_generate_slug(changeset) do
case get_change(changeset, :slug) do
nil ->
case get_change(changeset, :title) do
nil -> changeset
title -> put_change(changeset, :slug, slugify(title))
end
_ ->
changeset
end
end
defp slugify(text) do
text
|> String.downcase()
|> String.replace(~r/[^\w\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
end
end