Current section

Files

Jump to
Raw

CHANGELOG.md

# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## 0.2.2 - 2026-08-14
PR #21 plus the post-merge review in
`dev_docs/pull_requests/2026/21-stop-empty-slugs/GROK_REVIEW.md`.
**⚠️ Requires `phoenix_kit ~> 2.6`.** Shipping-method slugs call
`Slug.put_slug/3` (core 2.4.0) and product/category unique constraints name
V171's projection primary keys (core 2.6.0). A host still on 2.0–2.5 will not
resolve this release.
### Fixed
- **Empty slugs no longer lock a shop out of its own unique index.**
`Slug.slugify/2` returns `""` for scripts it cannot romanize (CJK, Arabic,
emoji). The old `extract_primary_slug` index was partial only on
`IS NOT NULL`, so the first unromanizable title took the `""` key and the
second could not be inserted. The generator never writes an empty result.
- **Unromanizable titles now get a storefront URL.** Skipping the empty key
fixed the lockout but left CJK-only products as `/shop/product/` — a
catalog card with nowhere to go. They now receive a stable `item-<hash>`
fallback, shared by Product and Category via
`PhoenixKitEcommerce.LocalizedSlug`. A leftover `{"en": ""}` row self-heals
into that fallback on its next save.
- **Slug resolution skips stored empty strings.** `""` is truthy, so
`SlugResolver.product_slug/2` used to emit it into `product_url/2` for a
legacy CJK row and produce `/shop/product/` even when another language had
a real slug.
- **Renaming a shipping method no longer moves its slug**, and a Cyrillic
name no longer writes `""` (which locked every later Cyrillic-named method
out via `phoenix_kit_shop_shipping_methods_slug_unique`). Shipping methods
now use core's `Slug.put_slug/3`.
- **A CJK/Arabic/emoji shipping-method name can be saved.** `slug` is
`NOT NULL`, and `put_slug/3` leaves it blank when slugify returns `""`,
which was a failed insert. The same `item-<hash>` fallback now fills it,
suffixing `-2` on collision.
- **Product and category slug collisions are changeset errors** on `:slug`,
not raw `Postgrex.Error`. The `unique_constraint` names follow V171's
projection primary keys.
- **Admin form labels were rendering at 60% opacity under daisyUI 5.**
`form-control` / `label-text` / `*-bordered` / `tabs-bordered` match
nothing in v5; the leftover `.label` rule is `color-mix(..., 60%)`.
Class remaps only.
### Changed
- **`:phoenix_kit` floor raised `~> 2.0``~> 2.6`** (two-segment, so later
2.x still resolve). The previous floor admitted cores without `put_slug/3`
and without V171.
## 0.2.1 - 2026-08-11
### Changed
- Dependency updates: `phoenix_kit` 2.2.0 and the transitive set it pulls
(`phoenix` 1.8.10, `hackney` 4.7.3). No source changes in this package.
## 0.2.0 - 2026-08-10
### Changed
- **⚠️ Requires `phoenix_kit ~> 2.0`.** The core pin moved to `~> 2.0`, so this
release no longer resolves against core 1.7.
Core 2.0.0 squashes the migration chain into a single `V135` baseline and makes
V135 the chain's floor: `mix ecto.migrate` now *refuses* on a database below it
rather than migrating. Check `mix phoenix_kit.status` **before** upgrading. A
host below V135 must install `phoenix_kit 1.7.236` — the migration bridge, the
last release carrying the full pre-squash chain — migrate until the reported
version is at least V135, and only then move to 2.0.
This package does not call migration internals, so the change is the pin
itself.
- Sibling pins raised in step, each to that package's first release requiring
core 2.0: `phoenix_kit_billing``~> 0.7`, `phoenix_kit_ai` (optional) → `~> 0.18`.
### Removed
- **`PhoenixKitEcommerce.Slugify` is deleted (PR #19).** It existed to apply a
German expansion table (`ö``oe`, `ß``ss`) before handing off to core,
because core's slug rule could not express a locale. Core 2.0.0 removes that
reason: `PhoenixKit.Utils.Slug` delegates to the `locale_slug` package and
`:locale` is first-class, so `Slug.slugify(text, locale: lang)` subsumes the
local table. There is now one slug implementation for the whole ecosystem.
### Changed
- **Each language's slug is generated as that language (PR #19).** Product and
category slugs pass the record's language through as `locale:`, so a German
title expands `ö`/`ß` while an Estonian one folds them — one table could not
do both, which is why the local copy existed. Stored slugs are not rewritten;
only newly generated ones change.
## Unreleased
### Added
- **Cart-activity notifications for shop operators.** Three individually
toggleable storefront signals — first item added to a cart, every item
added, and checkout started — delivered through core's notification layer
(each recipient's own channels: in-app, email, Telegram). New settings card
under Shop → Settings: per-event toggles plus a recipient list chosen from
shop administrators (empty list = all admins). Per-cart deduplication is an
atomic jsonb flag claim, so concurrent tabs cannot double-fire; the
checkout signal only fires on a connected LiveView mount, so crawlers and
static renders never claim the flag. All sends are best-effort: a
notification failure can never break the storefront action. Settings:
`shop_notify_cart_first_item`, `shop_notify_cart_item`,
`shop_notify_checkout_started`, `shop_notification_recipients`.
- **Shipping-skip modes.** `shop_shipping_skip_mode``off` (legacy hard
requirement), `fallback` (orders may proceed without a shipping method when
no active method covers the buyer's country), `always` (shipping step
disabled). Skipped orders carry `"shipping_skipped" => true` and a
`"shipping_skip_reason"` in order metadata, add no synthetic shipping line
item, charge no shipping amount, and suffix the operator's new-order
notification with "— shipping pending". Server-side conversion re-validates
the mode independently of the LiveView.
- **Configurable shipping-selection position.** `shop_shipping_selection_position`
`cart` (legacy) or `checkout`: the buyer picks a shipping method as a
checkout step after billing, when the destination country is known, so
methods are filtered by real country instead of the cart page's
country-blind list. With `fallback`, an uncovered country collapses the
step into a localized "we will contact you" notice.
- **Localized shipping-pending notices** (en/ru/et) on the checkout shipping
step and the order-completion page.
- **Admin UI for the two shipping settings.** `shop_shipping_skip_mode` and
`shop_shipping_selection_position` shipped read-only — the storefront and
the conversion honored both from day one, but nothing rendered a control,
so changing either meant writing the setting row by hand. Shop → Settings
now carries a "Shipping requirement" card for both, validated against
their closed enums and audit-logged for the skip mode.
- **AI product translation** (PR #17 carried the `phase4-catalog-ai` branch).
`PhoenixKitEcommerce.AITranslatable` implements PhoenixKitAI's
`Translatable` contract for products (`shop_product`): title, description,
body, seo_title and seo_description translated from an explicit source
language, merged under `FOR UPDATE` so concurrent per-language jobs never
drop a sibling, with its own prompt (seo fields are outside the shared
prompt's vocabulary). Slugs are never taken from the model — they are
regenerated locally from the translated title, and only when the target
language has none, so re-translation cannot change a published URL. The
product form grows a "Translate with AI" button and modal. `phoenix_kit_ai`
is an **optional** dependency: all of it compiles out when absent.
- **Multi-domain catalog SEO assigns.** `Web.SEOHelpers` builds
canonical / og / hreflang data for the catalog, category and product
pages, resolving a per-language canonical host through the workspace-wide
`config :phoenix_kit, :canonical_host_resolver` MFA (absent ⇒ today's
single-host behavior). hreflang inclusion is decided on the RAW slug map,
never on `SlugResolver`'s silent default-language fallback.
- **Explicit language wins on product URLs.** `/fr/<en-slug>` now redirects
to the French slug when the product is genuinely translated into French,
instead of bouncing back to the default language — the language switcher
could previously never leave the slug's own language.
### Fixed
- German titles now slugify with orthographic expansions (`Größe Fußball`
`groesse-fussball`, not `gro-e-fu-ball`): ä/ö/ü/ß map to ae/oe/ue/ss before
core's transliteration pass.
- Operator new-order and cart-activity notifications now show the product's
real localized title (the previous code matched a nonexistent `:name`
field and always fell back to "product").
- Orders converted without a shipping method can never inherit a stale
shipping charge: `calculate_shipping/3` returns 0 when no method is set.
- The billing-less checkout path (authenticated buyer, payment option not
requiring a billing profile) now routes through the same shipping-step
decision as every other path instead of jumping straight to review.
- **Checkout could dead-end under `shipping_selection_position: "checkout"`.**
Editing billing to a country the already-chosen method does not serve sent
the shopper to the cart page to "pick another" — but that setting is
exactly what makes the cart page render no shipping section, and
"Proceed to Checkout" walked straight back into the same rejection: a
closed loop with no way out. Checkout now drops the ineligible selection
and re-enters its own shipping step with the methods for the country just
entered.
- The blocked shipping step (no method covers the country, skipping off)
offers a way back to billing; it previously had neither Continue nor Back,
stranding the shopper on a step whose only remedy is a different address.
- The checkout step indicator no longer goes blank on the shipping step, and
the step's Continue button now requires a method from the current list
rather than any non-nil selection.
- The shipping step no longer clears a cart's shipping country when the
checkout path supplies none (a billing-less payment option), which
silently widened the method list back to the country-blind one.
- `shop_notification_recipients` is intersected with current
`shop.manage_carts` holders on every send: revoking someone's shop access
now stops their cart-activity feed instead of leaving them on a snapshot
list.
- `mix credo --strict` and `mix dialyzer` are green again (both failed as
merged, so `mix precommit` was red): `Translations.get/3` was called with a
rebuilt map where its spec requires a struct, plus two nesting-depth and
four nested-module-alias findings.
- Estonian and Russian catalogues complete again — the AI-translate UI
strings shipped untranslated.
- **One slug rule for products and categories** (PR #18).
`PhoenixKitEcommerce.Slugify` replaces the two private implementations that
had drifted twice — Cyrillic (categories stored an EMPTY slug for a
Russian-only name, so the category had no URL) and then German. German
category names now slug `groesse-fussball` rather than `gro-e-fu-ball`.
Slugs are only generated for a language that has none, so no existing URL
changes; this affects content created from here on.
⚠️ **Known limitation:** the German ä/ö/ü expansion is not language-scoped,
so Estonian and other languages using those characters slug with doubled
vowels (`Müük``mueuek`, where core alone would give `muuk`). Pinned by
test in `slugify_test.exs`; under investigation.
- Slug regression tests no longer require PostgreSQL to run: the pure cases
moved off `DataCase` (whose `:integration` moduletag excluded them on a
database-less checkout — the suite for a twice-recurring bug was the part
that did not run), leaving only the schema-parity check DB-backed. That
check now drives both schemas through their changeset rather than
comparing a raw function to a changeset.
- Removed a stale `glob_ex` entry from `mix.lock` (`mix deps.unlock
--check-unused` is clean again), and fixed three broken checkout tests:
a shipping-method fixture whose non-unique name collided on the unique
slug, an ambiguous `back_to_billing` selector, and a `flash-error` id in
the test layout that collided with the real flash core renders inside the
LiveView tree.
## 0.1.16 - 2026-08-08
The storefront-translation, services-vocabulary and price-on-request wave
(PR #16), plus the post-merge review in
`dev_docs/pull_requests/2026/16-storefront-i18n-services-price-on-request/CLAUDE_REVIEW.md`.
Two things a shop operator should know before upgrading. `shop_hide_zero_decimals`
existed in the merged PR but **rounded** every non-round price on the storefront
(40.50 rendered as "41"); it is fixed here and off by default either way. And
"price on request" lines snapshot an amount of `0`, so the cart and order totals
they appear in do not include them — the pages that show a total now say so.
### Added
- **Storefront i18n actually reaches the storefront.** The content language is a
DIALECT (`ru-RU`, `et-EE`) and this module ships `priv/gettext/{en,ru,et}`
plain codes — and Gettext does not fall back between them, so every lookup
missed and returned its English msgid however complete the catalogues were.
`Helpers.put_content_locale/1` resolves the dialect against the backend's known
locales and is called from every public `mount/3`, which runs once per process
for both the dead render and the connected mount. An unknown locale RESETS to
the shop's configured default rather than no-opping: the dead render reuses a
connection process across keep-alive requests, so a no-op served the previous
visitor's language to the next one.
- **`PhoenixKitEcommerce.Vocabulary`**`shop_catalog_vocabulary` decides what
the storefront calls what it sells: `"products"` (default, unchanged for every
existing install), `"services"`, or `"mixed"` (neutral wording). Each variant is
a SEPARATE complete `gettext` literal, not a noun swapped into a sentence:
Russian and Estonian inflect the noun for a case the surrounding sentence
chooses, so `"No %{noun} available"` cannot be translated for a noun the
translator never saw.
- **Price on request**`Product.metadata["_price_display"]["on_request"]`
suppresses the amount on the storefront while the product keeps the price the
operator quotes from. Snapshotted onto the cart line and forwarded into the
order's line items, never read live: `product_uuid` is `ON DELETE SET NULL`, and
a line agreed as "price on request" must not later render as a number. Written
only when true, so no existing product gains a key on its next save.
- **`shop_hide_zero_decimals`** — render `40` rather than `40.00` when the
fractional part is entirely zero. Storefront only; invoices, receipts and credit
notes keep two decimals, which is the auditable form.
- **One-click product status toggle** in the admin list (active ⇄ draft only —
leaving `archived` is a deliberate act). Re-reads the row rather than trusting
the last render, and audits both the success and the failure branch.
- **Translated storefront copy** — 48 new msgids this release, en/ru/et complete.
### Fixed
- **`shop_hide_zero_decimals` misstated prices** (review #1). Trimming is done by
handing billing a currency copy with `decimal_places: 0`, and
`Currency.format_amount/2` *rounds* to that precision — so with the setting on,
`40.50` rendered as "€41", `40.49` as "€40" and `1234.99` as "€1,235", on the
catalog price, the cart line, shipping, tax and the total. The places are now
zeroed only for an amount that rounding cannot change, which is the rule the
plain-code branches already applied.
- **The checkout review step and the product page's "already in cart" notice
rendered an on-request line as `0.00`** (review #2, #3) — the cart and
confirmation pages routed through `PriceDisplay`, the two pages between them did
not, one of them being the last page before the order is placed. All five line
render sites now share `PriceDisplay.line_on_request?/1`.
- **Cart and order totals no longer imply an on-request line is free** (review
#7). The amounts are unchanged — they are what billing charges — and the cart,
checkout, confirmation and order-details pages now disclose that items priced on
request are not included in the total.
- **~35 untranslated strings on the checkout page**, including the entire billing
form (`First Name *`, `Country *`, `Select country...`), the step labels,
`Confirm Order` and the order summary (review #4). Also
`"Failed to create order. Please try again."`, `"N product(s) found"`, the three
`admin_edit_label` assigns, and all eight add-to-cart failure toasts, which were
raw English in the function the PR edited (review #5).
- **The two public order pages translated against core's catalogue** (review #8),
so the locale this module sets on its own backend never applied to them: `Total`,
`Status`, `Pending`, `Completed`, `Clear`, `Next` and the `Access denied` flash
rendered English. They now use the module's backend. Admin pages' core-backend
labels are untouched — that is core's shared UI vocabulary.
- **"Showing X of Y items" and "N product(s) found" bypassed `Vocabulary`**
(review #6) — the first had been neutralized to "items" for every shop,
including a products shop whose heading two lines up says "Products". Both now
route through `Vocabulary.showing_of/2` and `Vocabulary.count_found/1`.
- **Cyrillic category slugs were stored empty.** `Category.slugify/1` used
`~r/[^\w\s-]/` without the `u` flag, which matches no Cyrillic, so a Russian-only
category name was stripped character by character and the category was left with
no URL. It now uses core's `Slug.slugify(text, transliterate: true)`, the same
slugifier `Product` was already on. Existing rows regenerate on their next save.
- **The confirmation page no longer promises an email nobody sends** to a
signed-in customer — the only mail checkout sends is the guest account
confirmation.
- **Mobile storefront navigation** — categories render as their own nav on the
catalog and category pages instead of hiding inside a drawer labelled "Filters"
(and no longer twice on a phone), and the filter button is suppressed when no
filters are configured rather than opening an empty drawer.
- **Duplicate cart link** removed from the catalog page; the translated
`storefront_bar` above it already carries one.
- **`compare_at_price` is suppressed for an on-request product** on the catalog
card and the product page — a struck-through price beside "Price on request" is
a number the customer was not shown.
## 0.1.15 - 2026-08-06
Dependency-contract release. PR #15 raised the core floor to the release that
actually ships the module the admin lists depend on; the post-merge review
found the sibling billing floor stale in the same way, only wider. Findings in
`dev_docs/pull_requests/2026/15-core-version-floor/CLAUDE_REVIEW.md`.
Nothing about this package's behaviour changed — `mix.lock` already carried
both dependencies well above the new floors. What changed is what a *consumer*
is allowed to resolve.
### Changed
- **`phoenix_kit` floor raised to `~> 1.7.231`** (PR #15). `Web.Products` and
`Web.Categories` `use PhoenixKitWeb.Live.UrlState`, which first shipped in
core 1.7.231, while the requirement still named 1.7.214. A consumer whose
resolution landed below it got a package referencing a module their core
does not have: a compile failure from source, or an `UndefinedFunctionError`
on `on_mount/4` the first time an admin opened the products or categories
list, since the `on_mount({:url_state, cfg})` tuple is baked into the
`.beam`. Verified by building against both releases — 1.7.230 fails to
compile, 1.7.231 is clean. The floor now also agrees with
`@payment_option_version 162`, core's V162 migration having shipped in the
same release.
- **`phoenix_kit_billing` floor raised to `~> 0.5.2`.** `~> 0.1` admitted
0.1.0, which predates the `PhoenixKitBilling` namespace entirely (it was
`PhoenixKit.Modules.Billing`, so every call site here is undefined), and
0.1.1–0.1.2, which have no tax API. More quietly, `Order.payment_option_uuid`
— the column `maybe_put_payment_option/2` writes once the host is migrated to
core V162 — only exists from billing 0.5.2. Below that, `cast/3` drops the
attr without an error and the order/payment-option linkage vanishes with
nothing reporting it.
### Added
- `test/phoenix_kit_ecommerce/dependency_floor_test.exs` — DB-free contract
test pinning the symbols the two floors exist to guarantee (`UrlState`,
`Migrations.Postgres.migrated_version_runtime/1`,
`Order.payment_option_uuid`, the billing tax trio). It asserts against the
resolved dependency, so it fails in any build where resolution lands below a
floor — the case this workspace's always-latest `mix.lock` can never
reproduce, which is why both defects had to be found by reading.
## 0.1.14 - 2026-08-05
The storefront layout, price-unit and permission wave (PR #13) and URL-backed
admin list state (PR #14), plus the post-merge reviews in
`dev_docs/pull_requests/2026/13-storefront-layout-price-units-permissions/`
and `dev_docs/pull_requests/2026/14-url-state-search/`.
⚠️ **Breaking on upgrade — authorization.** The `"shop"` permission is now
admin-area READ access, with four sub-permissions carrying the capabilities
(`shop.manage_catalog`, `shop.manage_carts`, `shop.manage_settings`,
`shop.run_imports`). Core auto-grants newly discovered sub-permissions to the
**Admin system role only**, so a CUSTOM role holding base `"shop"` keeps its
reads but loses every mutation until an operator re-grants. Secure by default
and deliberate, but it needs an operator action.
### Added
- **The storefront renders in the HOST's layout.** Every public page — catalog,
category, product, cart, checkout, confirmation — now goes through core's
`LayoutWrapper.app_layout` for guests and logged-in visitors alike. It used
to dispatch three ways, including a module-owned layout that bypassed the
host entirely (the reported bug) and the ADMIN dashboard layout for any
authenticated shopper. The category/filter sidebar the dashboard layout
carried now renders in the page templates for everyone, and a compact
in-content Shop/Cart bar covers hosts whose own header has no cart link
(`shop_show_cart_bar`, default on).
- **`PhoenixKitEcommerce.PriceDisplay`** — an optional per-language price unit
("per hour", "/m²", "в час") and "From" prefix, stored under one reserved
`Product.metadata["_price_display"]` namespace, no migration. `render/4`
takes an explicit context: `:catalog` may show "From", while `:selected`,
`:cart` and `:order` are exact and render the SNAPSHOTTED unit, so editing or
deleting a product cannot relabel a line a customer already agreed to. The
unit rides into the order's line items and reaches billing's invoices.
- **Four shop sub-permissions** with `PhoenixKitEcommerce.Web.Authz`: admin
tabs carry their key so the sidebar hides what a holder cannot use, and 48
mutating event handlers re-check, failing closed on a missing scope.
- **Notifications** for order placement (admins), order confirmation (the
customer, separately muteable) and import completion/failure. Recipients
union permission holders with Owner-role holders and `"*"` superadmins,
neither of which has permission rows.
- **URL-backed list state** on the Products and Categories admin lists (PR
#14): search, filters and page live in the query string, so a filtered list
is a shareable, reload-proof URL and Back returns to the previous query. The
list load also moved out of `mount/3`, which runs twice.
### Fixed
- **Money on the checkout review screen.** Two paths reached `:review` without
pricing the cart, and a cart broadcast from another tab was assigned into an
open review, dropping the country and its tax — so the total shown could
differ from the total charged.
- **The cheapest shipping method was not always the cheapest.** `Enum.min_by`
over `%Decimal{}` compares by Erlang term order, so `9.99` sorted above `10`
and cart mount could auto-select the dearer method.
- **Shipping eligibility was judged on total weight** while listing and pricing
used shippable weight, so a mixed cart was quoted a method and then refused
at checkout. `requires_shipping` is no longer decorative: digital-only carts
are neither forced through shipping selection nor charged for it.
- **The advertised price range under-quoted.** Map-shaped override modifiers
were skipped though the charged path parses them ("From $20" for an item that
charges $30), and an offered option value with no modifier entry was skipped
instead of contributing a zero delta, so a product whose cheapest option is
free advertised its most expensive combination.
- **Order history could be rewritten** — order pages preferred the LIVE billing
profile over the order's snapshot. Cart, checkout and order pages also
rendered today's default currency instead of the amount's own.
- **The price unit was rendered against the line TOTAL** on the order
confirmation and order-details pages, so a line of 2 hours at €40.00 per hour
read "€80.00 per hour". Both pages now render the total plain with the unit
price beside it, matching the cart page.
- **A disabled shop stayed fully browsable and purchasable.**
- **Products could be bought after being deactivated** — the conversion check
now runs on locked rows inside the transaction. `body_html` (where imports
put the full supplier description) is now rendered on the public product
page, under the same sanitization policy as `description`.
- **Blank-address orders**, now validated in the CONTEXT for both billing
paths, and the selected payment option is no longer discarded at conversion.
- **Imports: a re-import deleted what the feed said nothing about.** An omitted
column arrives as a blank, and that blank overwrote the stored value —
erasing descriptions in every language, images, vendor, and the admin's own
price modifiers, image mappings and custom metadata. Also: slug lookup
normalized `"en"` to `"en-US"` while every writer stores the shop's code
verbatim (404s and failed re-matches on a base-code shop); a Cyrillic title
slugified to an empty string, so a Ukrainian catalogue re-imported itself
every run; a variant set with no readable price published a FREE product
instead of failing; and rows with a blank handle all grouped together, so
unrelated products merged into one.
- **A malformed `?category=` or `?parent=` crashed the admin list LiveViews.**
Both reach Ecto as UUID query parameters, where a non-UUID raises
`Ecto.Query.CastError`; they now fall back to an unfiltered list.
- **Import-failure notifications went missing on non-ASCII catalogues.** The
reason string was truncated on bytes, splitting UTF-8 mid-character into a
value Postgres rejects — and the best-effort wrapper then swallowed it.
### Changed
- Stale `mix.lock` entries left by the preceding dependency upgrade (igniter,
sourceror, spitfire, rewrite, owl, ex_ast, glob_ex, text_diff) pruned, which
unblocks `mix precommit`.
## 0.1.13 - 2026-08-04
Checkout security and wrong-money fixes (PR #12), plus the post-merge review
in `dev_docs/pull_requests/2026/12-checkout-security-policy-settings/`.
0.1.12 was tagged but never published, so its changes ship here too.
### Added
- **`PhoenixKitEcommerce.Policy`** — seven admin-controllable policy keys behind
one module, so the settings UI and the enforcement points cannot disagree
about a default. Every key ships in its safe position and every reader fails
**closed** on a settings-layer error: `shop_order_lookup_policy`,
`shop_allow_raw_html_descriptions`, `shop_allow_svg_uploads`,
`shop_image_import_allow_private_networks`, `shop_default_tax_country`,
`shop_import_cleanup_scope`, `shop_legacy_cookie_until`. Genuine invariants
deliberately get no setting. All policy changes are activity-logged.
- **`css_sources/0`** — documented in the README since forever, never
implemented, so Tailwind purged every class used only by this module's
templates from the host build. The absence was masked whenever any other
PhoenixKit module was installed.
- **`PhoenixKitEcommerce.Import.Money`** — one supplier-money parser shared by
every import format.
- First test suites for `Options` pricing, the import money parser, the
security regressions, guest order access, and the admin-permission contract.
### Fixed
- **Billing-profile IDOR.** `select_profile` accepted any client-supplied uuid
and order creation copied that profile's name, address, phone and email onto
the attacker's order. Now checked at selection, at `confirm_order`, and again
in the context — `convert_cart_to_order/2` is public and re-exported, so it
must not depend on one caller remembering.
- **Order confirmation pages were world-readable.** Access was granted for any
order with a nil `user_uuid` and for any order whose owner was unconfirmed —
and guest checkout creates exactly such users. Orders now record the placing
shop session, with a `cart_uuid → cart.session_id` fallback so pre-existing
guest orders keep working.
- **Cart takeover on a shared browser.** The logged-in fallback matched carts by
`session_id` without the `is_nil(user_uuid)` guard, and the cookie outlives
logout by 30 days. The cookie is now signed, `SameSite=Lax`, Secure-on-HTTPS,
with a narrow one-time migration for pre-signing cookies.
- **Stored XSS on the storefront** via `sanitize={false}` and a bare `raw/1`.
- **SSRF in the CSV image importer**, three ways: redirects were never
re-validated, IPv4-mapped IPv6 bypassed the private-range check entirely
(`::ffff:127.0.0.1` read as public), and no guard existed before that.
Post-merge: both address families are now resolved, so an IPv6-only image
host is no longer blocked outright and a public-A/private-AAAA host no longer
slips through.
- **Draft, inactive and archived products were public and purchasable**, via two
separate paths.
- **Tax was zero on every order** — the cart page leaves `shipping_country` nil
and checkout never set it. Fixing that surfaced two more: tax was charged on
the whole subtotal while `CartItem` carries a `taxable` flag, and the review
screen showed a pre-tax total while conversion charged tax.
- **Percentage discounts did nothing** — the percent branch was gated on
`compare(sum, 0) == :gt`, so a negative percent was silently discarded.
**Price ranges came out inverted** (`Enum.min/max` compare `%Decimal{}` by
Erlang term order, not value). **Fixed-only prices were never rounded.**
Post-merge: all three also affected `get_price_range/3` — the storefront's
"From $X" — which kept its own copy of the arithmetic. Both functions now
share one helper.
- **CSV prices were silently wrong**`"12,50"` imported as 12, and the first
hardening multiplied `"1.2345"` by 10,000. Post-merge: the Shopify
`"Variant Price"` path still truncated the same way, and since `base_price` is
the minimum variant price, one mangled row priced every variant.
- **An unreadable price became free stock** — the parser returned nil and the
call site turned it into `Decimal.new(0)`. The row now fails validation.
- **Free shipping** via a method the cart had outgrown; **lost cart totals**
under concurrency; **cart edits could commit after the order was created**.
- Confirmation email moved outside the conversion transaction, so an SMTP
failure can no longer roll back a paid order.
- Import cleanup no longer deletes every empty category in the catalog.
- Five LiveViews had no `handle_info` catch-all; malformed `phx-value-*`
payloads crashed sockets through `String.to_integer/1`.
- Guest carts orphaned by a fabricated session id on the cross-language
product path.
### Changed
- Category admin actions re-check `Scope.has_module_access?(scope, "shop")`
rather than `can_access_admin_area?/1`, which is true for any permission
holder.
- 25 new strings extracted and translated into ru and et.
- Docs: the Settings Keys sections in `README.md` and `AGENTS.md` listed four
unprefixed keys the code never read.
## 0.1.12 - 2026-07-27
### Changed
- **Stop calling the deprecated `PhoenixKit.Users.Auth.Scope.admin?/1`.** All 4
call sites (`Web.Categories`) now call `Scope.can_access_admin_area?/1`, the
name core renamed it to in phoenix_kit 1.7.214. The old name is a pure
`@deprecated` delegate, so **no behavior change** — this only silences the
deprecation warning host apps were eating on every compile of this library,
with no way to fix it themselves.
- **Dependency floor raised to `phoenix_kit ~> 1.7.214`** (from `~> 1.7.189`) —
`can_access_admin_area?/1` does not exist below it, so an older core would be
an `UndefinedFunctionError` at call time rather than a warning. This was not
hypothetical: the lockfile was resolving 1.7.194.
- Dependency lockfile bumps: `phoenix_kit` 1.7.194 → 1.7.216, `phoenix_live_view`
1.2.7 → 1.2.8, `beamlab_ex_aws_sqs` 4.0.0 → 5.0.0, `beamlab_countries` 1.0.8 →
1.1.0, `fresco` 0.8.0 → 0.10.0, `etcher` 0.7.2 → 0.9.0, `ex_ast` 0.12.10 →
0.13.1, `elixir_make` 0.9.0 → 0.10.0, `mdex` 0.13.3 → 0.13.4, `mdex_native`
0.2.5 → 0.2.6, `hackney` 4.5.2 → 4.6.0, `req` 0.6.2 → 0.6.3, `tessera` 0.3.2 →
0.3.4, `bandit` 1.12.0 → 1.12.4, `igniter` 0.8.2 → 0.8.3, `leaf` 0.3.0 → 0.3.2,
plus `plug_crypto`, `mint`, `quic`, `lazy_html`, `glob_ex`, `earmark_parser`.
## 0.1.11 - 2026-07-16
### Added
- **Admin i18n wave.** All 12 admin LiveViews (carts, categories, category
form, dashboard, import show, imports, product detail, product form,
products, settings, shipping method form, shipping methods) are now
fully gettext-wrapped, using the module-owned `PhoenixKitEcommerce.Gettext`
backend consistently (replacing leftover `PhoenixKitWeb.Gettext` calls).
The `ru`/`et` catalogs were extended from 483 to 2,500+ msgids.
### Fixed
- **Gaps in the i18n wave above, found in post-merge review.** The
product-form Translations tab (title/slug/description/SEO field labels
and placeholders), several product-form help strings, the products-list
empty state, the CSV-import confirm-step summary, an import-details
notice, and a category-option delete confirmation were left unwrapped
and rendered in English regardless of locale — not caught by
`mix gettext.extract --check-up-to-date`, since that check only verifies
already-wrapped calls, not coverage. All wrapped and translated
(en/ru/et).
- **Cart and shipping-method page-header counts pluralize correctly.**
`carts.ex` and `shipping_methods.ex` used plain `gettext` for their
"%{count} ... total/configured" headers instead of `ngettext`, so the
Russian translation hardcoded a genitive-plural noun regardless of
count — rendering "1 корзин всего" / "1 методов настроено" at
`count = 1`. Converted to `ngettext`, matching the categories/products
pattern, with correct 3-form ru and 2-form et translations.
- Corrected 12 catalog entries gettext's fuzzy-matcher mis-filled from an
unrelated adjacent label while merging the strings above (e.g. "No
products found" pre-filled with the Russian/Estonian translation of "No
categories found").
See `dev_docs/pull_requests/2026/11-admin-i18n-wave/CLAUDE_REVIEW.md` for
the full review.
## 0.1.10 - 2026-07-12
### Fixed
- **Search terms are escaped before hitting ILIKE.** User input flowed
into the `%term%` pattern raw, so `%`/`_`/`\` acted as wildcards on a
route open to unauthenticated visitors: searching `100%` matched every
product containing "100", an SKU search for `AB_100` also matched
`ABX100`, and a term ending in `\` silently corrupted the pattern into
requiring a literal `%`. Terms are now length-capped (100 chars),
NUL-stripped (a crafted `%00` raised `Postgrex.Error`), and
LIKE-escaped in one choke point (`search_like_pattern/1`) shared by the
product, category, and admin-cart search filters. The cap also closes a
cheap unauthenticated seq-scan amplifier (unbounded multi-KB patterns
against six ILIKE evaluations per row, two of them `jsonb_each_text`
expansions).
- **Category pages now show a filters-aware zero-result state.** With an
active search (or price/vendor filter) matching nothing, the category
page claimed "No products in this category — check back soon" even
though the category has products; it now says the filters matched
nothing and offers a clear-filters affordance, matching the main
catalog page. Both the guest and authenticated layouts share one
`category_empty_state` component now.
- Documented (code comment) the cross-release ordering limitation of
`merge_missing_builtin_filters/1`'s below-minimum positioning scheme:
it holds within one merge pass; a hypothetical second sidebar-leading
built-in added in a future release should renormalize positions on
save instead of extending the scheme.
## 0.1.9 - 2026-07-11
### Added
- **Storefront product search.** A new built-in `search` filter type lets
customers search the public catalog (main page and category pages) by
product name, description, SKU (`metadata->>'sku'`), and tags — matching
in any configured language. Renders as a search box at the top of the
storefront filter sidebar, driven by a `?search=` URL param so results
are shareable and survive navigation. Enabled by default for new
installs; existing installs discover it on the Settings page (merged in
disabled via `merge_missing_builtin_filters/1`) and enable it with one
toggle.
- Context: `list_products/1` `:search` now also matches SKU and tags
(previously title/description only), so admin product search finds
products by article number too.
### Fixed
- **Storefront search no longer raises `ambiguous_column`.** The `:search`
SQL fragment referenced unqualified `title`/`description` columns, which
turn ambiguous the moment `:exclude_hidden_categories` joins the
categories table — the exact combination every public catalog query
uses. Columns are now bound through the product binding. Pinned by a
regression test.
- **`merge_missing_builtin_filters/1` no longer collides positions.** A
saved config from before `search` existed can already hold another
filter at the same default position (e.g. `price` at `0`); the merged-in
filter now sorts strictly below every saved filter's position instead of
reusing its default position outright, so it renders first once enabled
as intended, even on upgraded installs.
## 0.1.8 - 2026-06-08
### Added
- Developer tooling: `pk_dep/3` in `mix.exs` resolves any `phoenix_kit*` dependency from a local checkout when `<APP>_PATH` is exported (e.g. `PHOENIX_KIT_PATH=../phoenix_kit mix test`), for cross-repo development. Unset — the default — keeps the published Hex pin, so `mix hex.publish` and CI resolve exactly as before. A set-but-blank value (`PHOENIX_KIT_PATH=`) is also treated as unset rather than producing a broken `path: ""` dep. Documented in `AGENTS.md`.
### Changed
- Refresh dependency lock: `phoenix_kit` 1.7.131 → 1.7.133 and `phoenix_kit_billing` 0.4.0 → 0.5.0, plus transitive bumps (`bandit` 1.11.1 → 1.12.0, `etcher` 0.6.5 → 0.6.6, `fresco` 0.6.3 → 0.7.1, `req` 0.5.18 → 0.6.1, `spitfire` 0.3.12 → 0.3.13, `tesla` 1.18.3 → 1.20.0). Declared dependency requirements are unchanged; no library or migration code changed.
## 0.1.7 - 2026-06-05
### Changed
- Migrate the category and product admin forms to core form components. Non-translatable scalar fields (status, product_type, vendor, price/compare_at/cost, position) and the changeset-backed selects (`parent_uuid`, `featured_product_uuid`, `category_uuid`) now render through core `<.input>`/`<.select>`. An `assign_form/2` helper keeps `:changeset` (consumed by the multilang `TranslationTabs` fields) and `:form = to_form(changeset)` in sync, so inline validation errors render on `validate`. Translatable fields, dynamic-option selects, and the option/media machinery are intentionally left as-is.
- Route web body strings through the module's own `PhoenixKitEcommerce.Gettext` backend (was the parent app's `PhoenixKitWeb.Gettext`). Generic UI-chrome strings are still resolved against core to keep its vetted translations; shop-domain strings resolve in-module and ship `et`/`ru` catalogues (correct 2-form/3-form plurals).
### Fixed
- Inline validation errors now render on the migrated `<.select>` fields, via the `phoenix_kit` 1.7.131 core `Select` component (it now maps `field.errors` like `<.input>` does).
### Dependencies
- Bump `phoenix_kit` 1.7.130 → 1.7.131 (core `<.select>` surfaces `field.errors`) and `etcher` 0.5.5 → 0.6.5.
## 0.1.6 - 2026-06-04
### Fixed
- Order `tax_rate` persisted as `0` on cart→order conversion. `build_order_attrs/3` hardcoded a `0` rate while `tax_amount` was computed from the live rate, so every order recorded a 0% rate — breaking receipts, tax reports, and refund math that derive the percentage from the order. Now uses `get_tax_rate(cart)` (single source of truth). Pinned by an end-to-end regression test.
- `unique_constraint` names for `ShippingMethod` (`:slug`) and `ImportConfig` (`:name`, `:uuid`) did not match the actual DB indexes, so duplicate inserts raised `Ecto.ConstraintError` instead of returning `{:error, changeset}`. Pinned to the real index names.
- No-billing tax fallback applied the configured `billing_default_tax_rate` even when `billing_tax_enabled` was `false`; now gated on the enabled flag, matching the `PhoenixKitBilling`-loaded path.
- `DialectMapper.resolve_dialect/2``/1` after the `phoenix_kit` upgrade dropped the 2-arity form, restoring a clean `--warnings-as-errors` build.
### Added
- `PhoenixKitEcommerce.Activity` — PII-safe LiveView-layer activity-logging wrapper around `PhoenixKit.Activity`. Logs admin mutations (products incl. bulk, categories, shipping methods, import configs/runs) and never crashes the caller (no-ops when core's `PhoenixKit.Activity` is absent; rescues DB errors).
- `PhoenixKitEcommerce.Errors` — central gettext-backed mapping from error atoms to user-facing strings (`PhoenixKitEcommerce.Gettext` backend). Wired into the `:cart_not_active` and `:unknown_format` paths.
- Test harness: `DataCase`/`LiveCase`, test endpoint/router/layouts, schema via `PhoenixKit.Migration.ensure_current/2`, and full schema/context/LiveView coverage (6 → 183 tests).
### Changed
- Cap `include_keywords`/`exclude_keywords`/`exclude_phrases` import filters at 100 entries each with a friendly changeset error (was unbounded).
- Centralize the nil-currency `$` fallback as a single documented module attribute.
- Migrate the shipping-method form to core components (`<.input>`/`<.select>`/`<.textarea>` with `assign_form/2`) for inline validation errors.
- Log a warning on `billing_default_tax_rate` parse failure instead of silently falling back to `0`.
- Refresh `mix.lock` for the upgraded `phoenix_kit` and related dependencies.
## 0.1.5 - 2026-05-09
### Fixed
- Mark `PhoenixKit.Modules.Shop` compat module as `@moduledoc false` to silence ex_doc warnings about `defdelegate` pointing at hidden `@impl PhoenixKit.Module` callbacks. Docs-only change; no behavioural difference.
### Added
- Broaden i18n test coverage to iterate `admin_tabs/0 ++ settings_tabs/0 ++ user_dashboard_tabs/0` (was admin-only). Add direct `Gettext.gettext(EcommerceGettext, ...)` assertions independent of `Tab.localized_label/1`.
## 0.1.4 - 2026-05-08
### Added
- Per-module Gettext backend (`PhoenixKitEcommerce.Gettext`) with `en`/`ru`/`et` catalogues for all admin sidebar tab labels. Requires `phoenix_kit` release that ships the `gettext_backend` Tab API ([BeamLabEU/phoenix_kit#522](https://github.com/BeamLabEU/phoenix_kit/pull/522)); on older releases tabs render raw English (graceful degradation).
## 0.1.3 - 2026-04-06
### Added
- Add `version/0` callback to display package version on modules page
### Changed
- Remove deprecated `select-bordered` class for daisyUI 5 compatibility
- Expand compat module with full delegation list
- Add `elixirc_options: [ignore_module_conflict: true]` for umbrella compatibility
### Fixed
- Fix compilation errors after core changes
## 0.1.2 - 2026-03-30
### Added
- Compat alias modules (`lib/phoenix_kit_ecommerce/compat/`) bridging old `PhoenixKit.Modules.Shop.*` namespace to `PhoenixKitEcommerce.*` for backward compatibility
- Billing module as single source of truth for tax rates with Settings fallback
### Changed
- Remove explicit `LayoutWrapper.app_layout` from 14 admin LiveViews (core now auto-applies admin layout; kept in `shop_layouts.ex` for public storefront)
- Convert 5 admin list pages (carts, categories, products, shipping methods, import configs) to `table_default` + `table_row_menu` components
- Remove 65 duplicate files from old `lib/phoenix_kit/modules/shop/` namespace and 2 duplicate mix tasks
### Fixed
- Add admin authorization check (`Scope.admin?/1`) to individual category delete, matching bulk operations
- Replace raw `<tr><td>` empty-state rows with proper `table_default` components in shipping methods and import configs
- Fix weight formatting in shipping methods to use `Float.round/2` for precision consistency with carts
## 0.1.1 - 2026-03-29
### Changed
- Restructure from `lib/phoenix_kit/modules/shop/` to `lib/phoenix_kit_ecommerce/` matching standard extracted-repo convention
- Rename all modules from `PhoenixKit.Modules.Shop.*` to `PhoenixKitEcommerce.*`
- Update billing references from `PhoenixKit.Modules.Billing.*` to `PhoenixKitBilling.*`
- Move mix tasks to `lib/phoenix_kit_ecommerce/mix_tasks/`
- Add `:mix` to dialyzer PLT apps for clean dialyzer runs
### Fixed
- Fix 33 nesting depth violations (max depth 2) by extracting helper functions and using `with` chains
- Fix 11 cyclomatic complexity violations (max 9) by splitting large functions into multi-clause helpers
- Fix 25 alias ordering issues across all modules
- Fix dead code in `get_mapped_image/4` where `|| current_image` could never trigger
- Add `elixirc_paths/1`, `aliases/0` (quality/precommit) to mix.exs matching sibling conventions
### Added
- Comprehensive README with features, installation, usage examples, architecture, and troubleshooting
- AGENTS.md for AI agent guidance
- CHANGELOG.md for release tracking
- MIT LICENSE file
## 0.1.0 - 2026-03-29
### Added
- Initial e-commerce module with PhoenixKit.Module behaviour
- Product catalog with physical and digital product support
- Multi-language content (titles, slugs, descriptions, SEO fields)
- Hierarchical categories with nesting and per-category option schemas
- Dynamic product options with fixed and percentage price modifiers
- Shopping cart with guest (session-based) and user (persistent) modes
- Cart real-time sync across tabs via PubSub
- Checkout flow with PhoenixKitBilling integration for order conversion
- Shipping methods with weight/price constraints and geographic restrictions
- CSV import system with automatic format detection (Shopify, Prom.ua, generic)
- Oban workers for async CSV imports and image migration
- Admin dashboard with product, category, shipping, cart, and import management
- Public storefront pages (catalog, category, product detail, cart, checkout)
- User order history and order detail pages
- Import configuration profiles with keyword filtering and category rules
- Product deduplication mix task