Packages
phoenix_kit_ecommerce
0.6.0
0.6.0
0.5.24
0.5.23
0.5.22
0.5.21
0.5.20
0.5.19
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.0
0.3.0
0.2.2
0.2.1
0.2.0
0.1.16
0.1.15
0.1.14
0.1.13
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
E-commerce module for PhoenixKit — products, categories, cart, checkout
Current section
Files
Jump to
Current section
Files
phoenix_kit_ecommerce
README.md
README.md
# PhoenixKitEcommerce
[](https://elixir-lang.org)
[](LICENSE)
E-commerce module for [PhoenixKit](https://github.com/BeamLabEU/phoenix_kit). Products, categories, shopping cart, checkout, shipping, CSV imports, and multi-language support with real-time LiveView UI.
## Features
- **Product catalog** — physical and digital products with pricing, images, SEO fields, and draft/active/archived workflow
- **Dynamic options & pricing** — two-level option system (global + category-specific) with fixed and percentage price modifiers
- **Hierarchical categories** — nested categories with multi-language names, slugs, and per-category option schemas
- **Shopping cart** — guest (session-based) and user (persistent) carts with real-time cross-tab sync via PubSub
- **Checkout & payments** — integrated with [PhoenixKitBilling](https://github.com/BeamLabEU/phoenix_kit_billing) for order conversion and payment processing
- **Shipping methods** — weight-based and price-based constraints, geographic restrictions, free shipping thresholds, delivery estimates
- **CSV import** — automatic format detection (Shopify, Prom.ua, generic) with keyword filtering, category rules, and image migration
- **Multi-language** — localized titles, slugs, descriptions, and SEO metadata across products and categories
- **Real-time updates** — PubSub events for carts, products, categories, and inventory changes
- **Admin dashboard** — LiveViews for managing products, categories, shipping, carts, imports, and settings
- **User pages** — catalog, product detail, cart, checkout, order history, and order details
- **Public read API** — full-text product search (PostgreSQL, per language, typo-tolerant with `pg_trgm`), product cards, category tree and similar products for AI agents and scripts, as REST and MCP tools of PhoenixKit core (needs a core with the shop API; off until `api_public_enabled` is on)
- **Public cart API** — seven anonymous *write* tools that let a buyer's AI agent fill a cart and hand it to the site's checkout through a one-time link; no order, payment or address passes through the API (same switch, `api_public_enabled`)
- **Auto-discovery** — implements `PhoenixKit.Module` behaviour; PhoenixKit finds it at startup with zero config
## Installation
Add `phoenix_kit_ecommerce` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:phoenix_kit_ecommerce, "~> 0.5.0"}
]
end
```
Then fetch dependencies:
```bash
mix deps.get
```
> **Note:** For development or if not yet published to Hex, you can use:
> ```elixir
> {:phoenix_kit_ecommerce, github: "BeamLabEU/phoenix_kit_ecommerce"}
> ```
PhoenixKit auto-discovers the module at startup — no additional configuration needed.
## Quick Start
1. Add the dependency to `mix.exs`
2. Run `mix deps.get`
3. Add Oban queues to `config/config.exs`:
```elixir
config :my_app, Oban,
queues: [shop_imports: 5, shop_search: 2]
```
4. Run `mix phoenix_kit.update` to generate migrations
5. Enable the Shop module in Admin -> Modules
6. Configure shop settings at `/admin/shop/settings`
## Usage
### Products
```elixir
alias PhoenixKitEcommerce, as: Shop
# Create a product
{:ok, product} = Shop.create_product(%{
title: "Wireless Headphones",
slug: "wireless-headphones",
status: "draft",
price: Decimal.new("79.99"),
currency: "EUR",
product_type: "physical",
weight_grams: 250
})
# Publish the product
{:ok, product} = Shop.update_product(product, %{status: "active"})
# Multi-language support
{:ok, product} = Shop.create_product(%{
title: %{"en" => "Wireless Headphones", "uk" => "Бездротові навушники"},
slug: %{"en" => "wireless-headphones", "uk" => "bezdrotovi-navushnyky"},
price: Decimal.new("79.99"),
currency: "EUR"
})
# Look up by slug in any language
product = Shop.get_product_by_any_slug("bezdrotovi-navushnyky")
```
### Categories
```elixir
# Create a category hierarchy
{:ok, electronics} = Shop.create_category(%{
name: "Electronics",
slug: "electronics",
status: "active"
})
{:ok, audio} = Shop.create_category(%{
name: "Audio",
slug: "audio",
status: "active",
parent_uuid: electronics.uuid
})
# List categories for navigation menus
categories = Shop.list_menu_categories()
```
### Product Options & Dynamic Pricing
```elixir
# Options support fixed and percentage price modifiers
# Category-level option schema example:
option_schema = [
%{
"name" => "color",
"type" => "select",
"options" => ["Black", "White", "Red"],
"price_modifier" => %{"Red" => %{"type" => "fixed", "amount" => "5.00"}}
},
%{
"name" => "warranty",
"type" => "select",
"options" => ["1 Year", "3 Years"],
"price_modifier" => %{"3 Years" => %{"type" => "percent", "amount" => "20"}}
}
]
# Calculate final price with selected options
price = Shop.calculate_product_price(product, selected_specs)
```
`selected_specs` is `%{option_key => value}`. For a product of the shop's own
tables the value is the option value as typed. For a catalogue-backed product
it is the attribute-set value's **key** (its slug), the same in every
language — `product.metadata["_option_values"]` lists the keys on offer and
`PhoenixKitEcommerce.SelectedSpecs.value_label/3` gives the label to show.
A cart line stores the key and a snapshot of that label; read a line's
selection with `PhoenixKitEcommerce.SelectedSpecs.labels/1`.
### Shopping Cart
```elixir
# Get or create a cart (guest or authenticated)
{:ok, cart} = Shop.get_or_create_cart(user_uuid: user.uuid)
{:ok, cart} = Shop.get_or_create_cart(session_id: session_id)
# Add items
{:ok, cart} = Shop.add_to_cart(cart, product, %{quantity: 2, selected_specs: specs})
# Update quantity
{:ok, cart} = Shop.update_cart_item(cart, item_uuid, %{quantity: 3})
# Set shipping and payment
{:ok, cart} = Shop.set_cart_shipping(cart, shipping_method_uuid)
{:ok, cart} = Shop.set_cart_payment_option(cart, payment_option_uuid)
# Merge guest cart after login
{:ok, cart} = Shop.merge_guest_cart(session_id, user.uuid)
# Convert to order (integrates with Billing module).
# Takes the billing identity: an owned profile uuid, or guest billing data.
{:ok, order} = Shop.convert_cart_to_order(cart, billing_profile_uuid: profile.uuid)
{:ok, order} = Shop.convert_cart_to_order(cart, billing_data: %{"email" => "a@b.com"})
```
> Payments (Stripe etc.) are handled by **PhoenixKitBilling**. To configure and
> test payment providers — including running Stripe webhooks against
> `localhost` via the Stripe CLI — see **"Testing Stripe locally"** in the
> [phoenix_kit_billing README](https://github.com/BeamLabEU/phoenix_kit_billing#testing-stripe-locally).
### Shipping Methods
```elixir
{:ok, method} = Shop.create_shipping_method(%{
name: "Standard Delivery",
slug: "standard",
price: Decimal.new("5.99"),
currency: "EUR",
free_above_amount: Decimal.new("50.00"),
min_weight_grams: 0,
max_weight_grams: 30000,
estimated_days_min: 3,
estimated_days_max: 5,
countries_allowed: ["UA", "PL", "DE"],
active: true
})
# Get methods available for a specific cart
methods = Shop.get_available_shipping_methods(cart)
```
### Public API: search and product tools
With PhoenixKit core's shop API switched on (`api_public_enabled`), this module offers five read-only
search and product tools to anonymous callers (the cart tools, which write, are described below), over REST (`POST /api/shop/v1/<tool>`) and MCP (`/mcp/shop`):
| Tool | What it does |
|---|---|
| `shop.search_products` | Search by words, category branch, price, options, vendor and sale status; facets; exact count; cursor paging |
| `shop.search_products_batch` | Up to 20 searches in one call (one rate-limit unit each) |
| `shop.get_products` | Full cards for up to 50 ids (one rate-limit unit per ten ids); a discontinued product is a three-field stub; hidden or unknown ids are `not_found` |
| `shop.list_categories` | The active categories as a tree with product counts |
| `shop.similar_products` | Products in the same category or sharing an option value or tag |
```bash
curl -s -X POST https://shop.example.com/api/shop/v1/shop.search_products \
-H 'content-type: application/json' \
-d '{"q": "ceramic vase", "price_max": 30, "sort": "price_asc", "limit": 5}'
```
What a caller sees is decided by one rule (`PhoenixKitEcommerce.API.Visibility`): draft, inactive,
deleted products and products in hidden or deleted categories are never shown (every product and
card is read live); discontinued or archived ones appear only as a stub in `shop.get_products`.
The counts and facets come from the search index and can lag a change by the reindex delay (a few
seconds and the queue, or until the nightly rebuild if the job fails), so for that time they can
include a product the items no longer show, and a query only that product matches answers `count: 1`
with no items. The category counts of `shop.list_categories` come from the index too, and a
sub-category under an unlisted middle category counts towards its listed ancestor's search but not
towards that ancestor's `total_count`. Enabling the shop queues a rebuild; enabling a language does not, so call `PhoenixKitEcommerce.Search.Jobs.enqueue_rebuild/0` or wait for the nightly one (03:40). Prices come in the language and
currency of the domain the request came to (a host maps domains with
`config :phoenix_kit, :api_request_context, {Mod, :fun}`); `language` and `currency` inputs override.
Search reads `phoenix_kit_shop_search_index`, kept fresh by `PhoenixKitEcommerce.Search.Subscriber`
(catalogue and shop PubSub events, through Oban jobs on the `shop_search` queue) and rebuilt every
night (`oban_crons/0`; `mix phoenix_kit.update` adds the queue and the cron entry to the host's
Oban config). Without the `pg_trgm` extension search still works and only skips the typo fallback.
### Public API: cart tools
The same switch (`api_public_enabled`) also turns on seven anonymous **write** tools. They create
cart rows, can send the owner a notification and mint checkout links, so decide on purpose whether
to switch the public API on. They never take an order, a payment or an address: the buyer finishes
on the site's own checkout.
| Tool | What it does |
|---|---|
| `shop.create_cart` | Returns a cart token; writes nothing |
| `shop.get_cart` | The cart: lines, totals, shipping choice and a `revision` |
| `shop.set_cart_items` | Sets lines to final quantities in one transaction, per-line errors, `expected_revision`, `dry_run` |
| `shop.clear_cart` | Empties the cart; asks for confirmation when it has lines (offered only when core carries the confirmation gate) |
| `shop.get_shipping_options` | Shipping methods for a country |
| `shop.set_shipping` | Chooses one of them |
| `shop.get_checkout_link` | A one-time link to the checkout hand-off page |
- **Token and row.** A cart token is 32 random bytes plus a short MAC, signed with the host's
`secret_key_base` and never stored. The cart row is created by the first write only, under core's
per-IP and global cart-row limits (`session_id` starts with `api:`).
- **Hand-off.** The link opens `/checkout/claim/:token` (no locale prefix). A GET shows the cart and
consumes nothing; the buyer's confirmation (a CSRF `POST`) consumes it once and moves the cart to
the buyer's browser session, replaces the cart already there, or merges into it. The old token then
answers `cart_locked`. The claim page marks itself as not to be tracked by the web analytics module
(`PhoenixKitWebAnalytics.Plug.skip/1`, when the installed version has it), and the V5 migration step
also adds `/checkout/claim/*` to `web_analytics_exclude_paths` if the analytics tables existed when
it ran; if analytics was installed later, add that pattern yourself. **The claim token is part of
the request path, and this module cannot keep it out of your logs:** the path is written before
any controller runs, by the endpoint's request logger (`Plug.Telemetry`, the `GET /checkout/claim/...`
line) and by core's `RequestTimer` (a `START` line at debug and an `END` line at info, both with
the path; it is part of the `:phoenix_kit_auto_setup` pipeline the claim route goes through). The
`Parameters:` line is already `[FILTERED]` by core's `filter_parameters`, which covers parameters
and not the path. Anyone who can read the log can open a live link before the buyer, for up to
`api_checkout_claim_ttl_minutes`, so drop these lines in the host with a primary `:logger` filter,
which covers all three at once:
```elixir
defmodule MyApp.ClaimLogFilter do
@moduledoc false
def filter(%{msg: {:string, text}}, _extra) do
if text |> IO.chardata_to_string() |> String.contains?("/checkout/claim/"),
do: :stop,
else: :ignore
end
def filter(_event, _extra), do: :ignore
end
# In MyApp.Application.start/2, before the supervisor starts:
:logger.add_primary_filter(:claim_token, {&MyApp.ClaimLogFilter.filter/2, []})
```
Keep the link lifetime short (the default is 30 minutes). A fix at the source belongs to core
(skipping the path in `RequestTimer`) and to the host's endpoint (`Plug.Telemetry`'s `log:` option
can return `false` for these paths).
- **Notifications.** An API write sends at most one message ("via assistant") through the two
existing cart notification actions and toggles, capped per cart and globally per hour.
- **Settings** (E-Commerce settings page, "Assistant carts" card): `api_cart_idle_days` (7),
`api_checkout_claim_ttl_minutes` (30), `api_signal_per_cart_per_hour` (3),
`api_signal_global_per_hour` (30).
- **Cron.** `oban_crons/0` adds an hourly sweep of idle `api:` carts, a daily deletion of
abandoned `api:` carts (with their lines) and a daily prune of used or expired claims (seven days
after a hand-off the old token stops answering `cart_locked`: it reads as an empty open cart, or
`cart_not_found` if the cart was merged); general cart expiry is unchanged. Run `mix phoenix_kit.update`: it applies migration V5 (`phoenix_kit_shop_cart_claims`)
and writes the cron entries into the host's Oban crontab.
### CSV Import
```elixir
# Imports are driven from the admin wizard at /admin/shop/imports, which
# uploads the file, detects the format (Shopify, Prom.ua, generic), creates
# an ImportLog and enqueues PhoenixKitEcommerce.Workers.CSVImportWorker.
#
# `start_import/2` is the state transition on an existing log, not an entry
# point that takes a path:
{:ok, log} = Shop.start_import(import_log, total_rows)
# Progress is broadcast on "shop:import:<uuid>" and rendered live at
# /admin/shop/imports/:uuid
```
### Admin API tools
On a `phoenix_kit` core that ships the admin API (the tool registry), this
module offers five admin tools over REST, and over MCP where the core also
provides it, for an accounting system, a script or an AI agent. Shopify is not
needed: on a shop without it, the API is how an outside system feeds the
product list.
| Tool | Does | Scope |
|---|---|---|
| `ecommerce.get_prices` | prices, option prices, visibility | `ecommerce.prices:read` |
| `ecommerce.set_prices` | price, compare-at price, option prices | `ecommerce.prices:write` |
| `ecommerce.set_availability` | visibility, made to order | `ecommerce.availability:write` |
| `ecommerce.attach_images` | pictures by URL, at most 20 per call | `ecommerce.images:write` |
| `ecommerce.detach_images` | takes pictures out of a gallery, by file id or by the URL they came from | `ecommerce.images:write` |
The key owner needs `shop` to read prices, and both `shop.manage_catalog`
and `catalogue` to use a write tool, including a dry run. Catalogue access
is checked against the owner's current roles on every call; a confirmation
does not preserve a permission that has since been revoked.
They work on the catalogue product source only; on the legacy source (or with
a `phoenix_kit_catalogue` that predates the admin API) they answer
`unsupported_source`. A price move of more than `api_confirm_price_change_pct`
percent (an option price is measured against the product's current price;
default 30, set on the E-Commerce settings page by a user who also
holds core's `api` permission) is held for confirmation first, and so is any
batch larger than core's `api_confirm_batch_over`. The price threshold checks
the base price and each option modifier independently; their changes are not
combined into a customer selection's total. `detach_images` is held when
a row would leave a product with no picture at all (the main picture alone is
not held: replacing it is an attach followed by a detach); a picture shared
with another product stays with it, and one nothing else holds goes to the
media trash, not out of Storage; at most 50 pictures per product and 500 per
call. A dry run (`dry_run: true`) of a write tool answers `requires_confirmation`
(whether the real call would be held, by the tool's rule if it has one, or the
batch size) and `confirmation_rules` (the thresholds that applied); it never
creates a confirmation code. Keys, scopes and the switch are on core's
Settings → API page.
A `validation_failed` answer with `context.reason` `database_error`,
`storage_error` or `unexpected` is not a problem with the request and is worth
retrying; any other `validation_failed` should be fixed first.
The module also offers the `shop_order` kind to Warehouse, so a shop order can
be the basis of a goods issue. This needs the Warehouse module 0.5.3 or later,
the first release where modules can declare source kinds.
### Shopify Sync
One-way, human-confirmed sync that keeps existing products current
against a connected Shopify store — **not** a product importer. It
updates products the shop already has (matched to Shopify by
handle/slug); a Shopify product with no local match is skipped. To
bring a new product in from Shopify, use [CSV Import](#csv-import)
first, then this to keep it current.
Setup (all through the UI, no environment variables). Shopify sync is
off by default: first turn on **Enable Shopify sync** in Shop → Settings.
Until then the Shopify option is not offered under Settings →
Integrations, and the sync page and its sidebar entry are hidden.
1. **In Shopify admin**: Settings → Apps and sales channels → Develop
apps → Create an app. Configure Admin API scopes — `read_products`
is the only one this needs. Install the app, then reveal and copy
its Admin API access token (shown once).
2. **In your PhoenixKit host app's admin**: Settings → Integrations →
Add Connection → Shopify. Enter the shop domain
(`your-store.myshopify.com`) and the access token from step 1, then
save. Credentials are encrypted at rest by core's
`PhoenixKit.Integrations` store — this module never sees or stores
them itself.
> The generic "Test Connection" button on that page reports success
> without actually contacting Shopify (Shopify isn't one of core's
> built-in validation strategies) — the "Check for changes" button
> below is the real connectivity test.
3. **In the shop admin**: Shop → Shopify Sync → "Check for changes".
Differences are grouped into one collapsed section per field —
Prices, Titles, Descriptions, HTML texts, Tags, Statuses, Vendors —
each showing how many products differ on it. Expand a section to
list them, 25 rows to a page; expand a row for a word-level diff of
what actually changed inside the text.
Apply at whichever scope fits: a single field of one product, every
product in one section, a checkbox selection within the current
page, or everything at once. Each is confirmed in a dialog that
states the count and the field before anything is written.
**Extreme price changes (a swing beyond 3x) are excluded from every
bulk scope** and must be applied per row, where the product and its
badge are in front of you. Nothing is written without an explicit
confirmation.
Fields compared: title, description (derived from Shopify's `Body
(HTML)`), the HTML body itself, vendor, tags, status, and price (the
lowest current variant price). Not synced: new products,
categories/collections, images, variants/options, and inventory
quantity.
**Base currency first.** While the shop has no base currency, the sync
writes no price and adds no product: an amount stored with no currency
would be read as whatever the base later becomes. The sync page shows a
banner linking to the currency settings and disables the price actions;
titles, descriptions, tags, images and collections still sync. To find
prices written before a base existed (read-only — it stamps nothing):
```bash
mix phoenix_kit_ecommerce.list_unstamped_prices
```
**One background sync at a time.** The images, variants and collections
syncs share one queue slot: starting one while another is queued or
running is refused and the page names the one in progress.
```elixir
alias PhoenixKitEcommerce.Shopify.Sync
# integration_uuid comes from PhoenixKit.Integrations.list_connections("shopify")
{:ok,
%{
changes: changes,
source: source,
fallback_reason: reason,
total_shopify_products: total_shopify_products,
matched_local_products: matched_local_products
}} = Sync.check(integration_uuid)
# `source` is :admin (the full diff above) or :storefront — a price-only
# fallback used when the Admin API token was rejected, with `reason`
# saying why (e.g. :unauthorized). Always check `source` before treating
# `changes` as a complete diff.
#
# `total_shopify_products` (every product Source.fetch/2 returned) and
# `matched_local_products` (how many of those matched a local product by
# handle, via ProductDiff.matched_count/3) are what "how much of the
# Shopify catalog can this sync even see" needs — only comparable to
# each other when `source == :admin`; the storefront fallback only ever
# sees products published to the Online Store, a narrower population.
# Apply everything that changed for one product...
{:ok, product} = Sync.apply_change(change)
# ...or only specific fields:
{:ok, product} = Sync.apply_change(change, [:price])
```
### Real-Time Events
Subscribe to shop events in your LiveViews:
```elixir
def mount(_params, _session, socket) do
# Pick the topic that matches how the visitor is identified:
PhoenixKitEcommerce.Events.subscribe_to_user_cart(user_uuid)
# ...or, for a guest:
# PhoenixKitEcommerce.Events.subscribe_to_session_cart(session_id)
{:ok, socket}
end
def handle_info({:cart_updated, cart}, socket) do
{:noreply, assign(socket, :cart, cart)}
end
```
### Settings
Keys are `shop_`-prefixed, except `api_confirm_price_change_pct`. (Earlier revisions of this table listed
unprefixed names that the code never read.)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `shop_enabled` | boolean | `false` | Module master switch |
| `shop_inventory_tracking` | boolean | `true` | Track product inventory |
| `shop_allow_price_override` | boolean | `false` | Allow per-product price overrides |
| `shop_category_name_display` | string | `"truncate"` | `"truncate"` or `"wrap"` |
| `shop_category_icon_mode` | string | `"none"` | Category icon rendering |
| `shop_sidebar_show_categories` | boolean | `true` | Show the category sidebar |
| `shop_catalog_vocabulary` | string | `"products"` | `"products"`, `"services"` or `"mixed"` — what the storefront calls what you sell |
| `shop_hide_zero_decimals` | boolean | `false` | Render `40` rather than `40.00` when the fraction is all zeros. **Storefront only** |
| `api_confirm_price_change_pct` | integer | `30` | Admin API: a price move above this percent needs a confirmation. Not `shop_`-prefixed |
`shop_catalog_vocabulary` exists because a shop selling colour grading reads
badly under "No products match your filters". Each option is a separately
translated set of complete sentences rather than a swapped noun — Russian and
Estonian inflect the noun for a case the sentence chooses, so a template with a
placeholder cannot be translated correctly. Read it through
`PhoenixKitEcommerce.Vocabulary`, never directly.
`shop_hide_zero_decimals` affects the **storefront only**. Invoices, receipts and
credit notes keep two decimals, which is the auditable form.
Tax comes from the Billing module (`billing_tax_enabled`,
`billing_default_tax_rate`), not from shop.
**Policy keys** — read through `PhoenixKitEcommerce.Policy`, never directly.
All are secure by default and fail *closed*; each is editable on the
E-Commerce settings page.
| Key | Default | Effect of the default |
|-----|---------|-----------------------|
| `shop_order_lookup_policy` | `"strict"` | An order page needs the session that placed it |
| `shop_allow_raw_html_descriptions` | `"false"` | Product descriptions are sanitized |
| `shop_allow_svg_uploads` | `"false"` | SVG is rejected by the image importer |
| `shop_image_import_allow_private_networks` | `"false"` | Importer refuses loopback/private addresses |
| `shop_default_tax_country` | `""` | No guessed jurisdiction; tax needs a real address |
| `shop_import_cleanup_scope` | `"auto_created"` | Cleanup only removes categories the import created |
| `shop_legacy_cookie_until` | *(unset)* | ⚠️ Pre-signing cart cookies are still adopted — set a date to close the window |
### Cart Status Workflow
| Status | Description |
|--------|-------------|
| `active` | Cart is in use |
| `merged` | Guest cart merged into user cart after login |
| `converted` | Cart converted to an order via checkout |
| `abandoned` | Cart inactive past threshold |
| `expired` | Session-based cart past 30-day expiry |
```
active → converted (checkout)
→ merged (login)
→ abandoned (inactivity)
→ expired (30 days)
```
### Permissions
The module declares permissions via `permission_metadata/0`:
- `"shop"` — access to the e-commerce admin dashboard and all sub-pages
Use `Scope.has_module_access?/2` to check permissions in your application.
### CSS Requirements
This module implements `css_sources/0` returning `[:phoenix_kit_ecommerce]`, so PhoenixKit's installer automatically adds the correct `@source` directive to your `app.css` for Tailwind scanning. No manual configuration needed.
### SEO tags in the host's root layout
The catalog, category and product pages assign three values for the host's
root layout to put in `<head>`:
- `og` — `%{type, title, description, image, url, locale}` (keys present when known)
- `canonical_url` — the page's absolute canonical URL (the same value as `og.url`)
- `hreflang_links` — `[%{code: "en", url: "https://…"}, …]`, one per language
the page exists in; empty when there is only one
PhoenixKit's own root layout reads `og` only: it emits the Open Graph tags and
a canonical link from `og.url`, and no `hreflang` alternates. A host that
wants language alternates — or that uses its own root layout — renders them
itself:
```heex
<link :if={assigns[:canonical_url]} rel="canonical" href={assigns[:canonical_url]} />
<link
:for={link <- assigns[:hreflang_links] || []}
rel="alternate"
hreflang={link.code}
href={link.url}
/>
```
Render the canonical link once: skip the first line under PhoenixKit's root
layout, which already emits it.
### Order notifications — two switches, both off by default
The module registers `shop.order_placed` and `shop.order_confirmed`, but
**registering a notification type is not enough to deliver one**. Two settings
gate it, and on a fresh install both are closed:
1. **Global notifications must be on.** With `notifications_enabled` off,
`shop.order_placed` fires into silence — no error, no log line. The module
looks broken rather than disabled.
2. **The recipient must be opted into a channel for that type.** External
channels are fail-closed per type, so a recipient with notifications enabled
but no Email channel on `shop.orders` still gets nothing.
Until both are set, an order arrives with no alert of any kind and the only way
to notice is to open the admin panel. A shop running in production lost a real
customer order this way before spotting it.
Recipients are the holders of the relevant permission **unioned with Owner-role
holders and `"*"` superadmins** — neither of those has permission rows, so a
key-only query misses the primary operator of a default install.
⚠️ Note that the order-confirmation page's copy is independent of all this. It
tells guests that an account-confirmation email is on its way, which is true and
is the only mail checkout sends. It does **not** promise customers an order
confirmation, because the module does not send one.
## Architecture
```
lib/
├── phoenix_kit_ecommerce.ex # Main context (PhoenixKit.Module behaviour)
└── phoenix_kit_ecommerce/
├── mix_tasks/
│ ├── phoenix_kit_ecommerce.install.ex # Install mix task
│ └── phoenix_kit_ecommerce.deduplicate_products.ex # Dedup utility
├── events.ex # PubSub event broadcasting
├── translations.ex # Multi-language utilities
├── slug_resolver.ex # Multi-language slug lookup
├── schemas/
│ ├── product.ex # Product schema
│ ├── category.ex # Category with nesting
│ ├── cart.ex # Shopping cart
│ ├── cart_item.ex # Cart line items
│ ├── shipping_method.ex # Shipping options
│ ├── shop_config.ex # Key-value config store
│ ├── import_config.ex # Import profiles
│ └── import_log.ex # Import tracking
├── options/
│ ├── options.ex # Option management context
│ ├── option_types.ex # Type system & validation
│ └── metadata_validator.ex # Metadata validation
├── import/
│ ├── import_format.ex # Format behaviour
│ ├── format_detector.ex # Auto-detect CSV format
│ ├── csv_parser.ex # CSV parsing
│ ├── csv_validator.ex # CSV validation
│ ├── csv_analyzer.ex # CSV analysis
│ ├── shopify_csv.ex # Shopify format parser
│ ├── shopify_format.ex # Shopify format implementation
│ ├── prom_ua_format.ex # Prom.ua format implementation
│ ├── product_transformer.ex # CSV row -> product
│ ├── option_builder.ex # Option creation from CSV
│ └── filter.ex # Keyword filtering
├── services/
│ ├── image_downloader.ex # Download images from URLs
│ └── image_migration.ex # Batch image storage
├── shopify/
│ ├── provider.ex # PhoenixKit.Integrations provider definition
│ ├── admin_client.ex # Shopify Admin API client
│ ├── storefront_client.ex # Public storefront JSON client (price-only fallback)
│ ├── source.ex # Admin-primary / storefront-fallback picker
│ ├── product_diff.ex # Local-vs-Shopify field diff, by handle
│ ├── text_diff.ex # Word-level diff for text fields
│ └── sync.ex # Orchestrates fetch, diff, and apply
├── workers/
│ ├── csv_import_worker.ex # Oban: async CSV import
│ └── image_migration_worker.ex # Oban: batch image processing
└── web/
├── routes.ex # Route definitions
├── shop_web.ex # Web module config
├── helpers.ex # Template helpers
├── shop_catalog.ex # Public: catalog page
├── catalog_category.ex # Public: category browse
├── catalog_product.ex # Public: product detail
├── cart_page.ex # Public: cart
├── checkout_page.ex # Public: checkout
├── checkout_complete.ex # Public: order confirmation
├── user_orders.ex # Public: order history
├── user_order_details.ex # Public: order details
├── dashboard.ex # Admin: overview
├── products.ex # Admin: product list
├── product_form.ex # Admin: product editor
├── product_detail.ex # Admin: product detail
├── categories.ex # Admin: category list
├── category_form.ex # Admin: category editor
├── shipping_methods.ex # Admin: shipping list
├── shipping_method_form.ex # Admin: shipping editor
├── carts.ex # Admin: cart analytics
├── settings.ex # Admin: settings
├── options_settings.ex # Admin: global options
├── imports.ex # Admin: import list
├── import_configs.ex # Admin: import profiles
├── import_show.ex # Admin: import details
├── shopify_sync.ex # Admin: Shopify catalog sync
├── test_shop.ex # Admin: testing UI
├── option_state.ex # Client option state
├── components/
│ ├── shop_layouts.ex # Layout wrappers
│ ├── shop_cards.ex # Product cards
│ ├── catalog_sidebar.ex # Filter sidebar
│ ├── filter_helpers.ex # Dynamic filters
│ └── translation_tabs.ex # Multi-lang editing
└── plugs/
└── shop_session.ex # Guest cart session
```
### Database Tables
| Table | Description |
|-------|-------------|
| `phoenix_kit_products` | Product catalog (UUIDv7 PK) |
| `phoenix_kit_categories` | Hierarchical categories |
| `phoenix_kit_carts` | Shopping carts (guest + user) |
| `phoenix_kit_cart_items` | Cart line items with price snapshots |
| `phoenix_kit_shipping_methods` | Shipping options and constraints |
| `phoenix_kit_shop_configs` | Key-value shop configuration |
| `phoenix_kit_import_configs` | CSV import profiles |
| `phoenix_kit_import_logs` | Import job tracking and progress |
### Routes
**Public:**
| Path | Description |
|------|-------------|
| `/shop` | Product catalog with filtering |
| `/shop/category/:slug` | Category browse |
| `/shop/product/:slug` | Product detail page |
| `/cart` | Shopping cart |
| `/checkout` | Checkout flow |
| `/checkout/complete/:uuid` | Order confirmation |
| `/checkout/claim/:token` | Hand-off page of the public cart API (GET shows, POST confirms; no locale prefix) |
**Admin:**
| Path | Description |
|------|-------------|
| `/admin/shop` | Dashboard & statistics |
| `/admin/shop/products` | Product management |
| `/admin/shop/categories` | Category management |
| `/admin/shop/shipping` | Shipping methods |
| `/admin/shop/carts` | Cart analytics |
| `/admin/shop/imports` | CSV import jobs |
| `/admin/shop/shopify-sync` | Shopify catalog sync |
| `/admin/shop/settings` | Shop configuration |
| `/admin/shop/settings/options` | Global option schemas |
| `/admin/shop/settings/import-configs` | Import profiles |
**Customer account** (login required; declared by core's router integration,
not by this package's route module):
| Path | Description |
|------|-------------|
| `/dashboard/orders` | The customer's orders (`PhoenixKitEcommerce.Web.UserOrders`) |
| `/dashboard/orders/:uuid` | One order (`PhoenixKitEcommerce.Web.UserOrderDetails`) |
All public routes support localized variants via `public_live_locale_routes/0`.
### Deprecated: the `PhoenixKit.Modules.Shop` namespace
The names this package had before it was extracted from core still compile,
and every one is deprecated: calling one prints a compiler warning naming
its replacement. They are removed in the release after this one.
| Deprecated | Use instead |
|------------|-------------|
| `PhoenixKit.Modules.Shop.<function>` | `PhoenixKitEcommerce.<function>` (same name and arguments) |
| `PhoenixKit.Modules.Shop.Cart` | `PhoenixKitEcommerce.Cart` |
| `PhoenixKit.Modules.Shop.Web.Routes` | `PhoenixKitEcommerce.Web.Routes` |
| `PhoenixKit.Modules.Shop.Web.Plugs.ShopSession` | `PhoenixKitEcommerce.Web.Plugs.ShopSession` |
| `PhoenixKit.Modules.Shop.Web.UserOrders` | `PhoenixKitEcommerce.Web.UserOrders` |
| `PhoenixKit.Modules.Shop.Web.UserOrderDetails` | `PhoenixKitEcommerce.Web.UserOrderDetails` |
A host router that declares the customer order pages itself switches one
line each — `live "/dashboard/orders", PhoenixKitEcommerce.Web.UserOrders, :index`
— or drops both lines: core declares the two routes whenever this package
is installed.
## Development
```bash
mix deps.get # Install dependencies
mix test # Run tests
mix format # Format code
mix credo --strict # Static analysis (strict mode)
mix dialyzer # Type checking
mix docs # Generate documentation
mix precommit # Compile + format + credo + dialyzer
mix quality # Format + credo + dialyzer
```
### Testing
The suite has unit tests (always run, no DB) and integration tests
(tagged `:integration`, auto-excluded when PostgreSQL is unavailable).
Run the integration tests after a one-off database create:
```bash
createdb phoenix_kit_ecommerce_test # one-time setup
mix test # boots Test.Repo, runs core migrations, sandboxes per test
```
Case templates live in `test/support/`: `PhoenixKitEcommerce.DataCase`
(context/schema tests) and `PhoenixKitEcommerce.LiveCase` (LiveView
tests). The test repo runs core's versioned migrations via
`PhoenixKit.Migration.ensure_current/2` — no module-owned DDL.
## Troubleshooting
### Shop not appearing in admin
- Verify the module is enabled in Admin -> Modules
- Ensure the module is listed as a dependency in the parent app's `mix.exs`
- Check that `enabled?/0` is not returning `false` (requires database access)
### CSV imports not processing
- Ensure Oban is configured with the `shop_imports` queue (every shop worker — CSV import, image migration, Shopify media sync — runs on it)
- Check Oban dashboard for failed jobs
- Review import logs at `/admin/shop/imports` for error details
### Search finds nothing
- Ensure Oban is configured with the `shop_search` queue (`queues: [shop_imports: 5, shop_search: 2]`). Without it the reindex and rebuild jobs stay `available` and the search index stays empty
- An empty index is rebuilt by a job enqueued 30 seconds after boot, and every night; check the Oban dashboard for failed `SearchRebuildWorker` jobs
- Typos are found only when the `pg_trgm` extension is installed and its operators are on the connection's search path; search works without it and logs a warning at boot
### Guest cart not persisting
- Verify `ShopSession` plug is included in your router pipeline
- Check that session cookies are configured correctly
### Images not downloading during import
- Ensure the `shop_imports` Oban queue is running
- Check that `download_images` is enabled in the import config
- Review image migration worker logs for HTTP errors
## License
MIT -- see [LICENSE](LICENSE) for details.