The new Istrian oil harvest has landed · Free delivery over 60 €

Solina

Plugin Forge 1.9.0

Shop

Products, basket, checkout and orders — the whole commerce domain.

Shop is the plugin the storefront runs on. October themes cannot execute PHP, so everything with behaviour lives here: the models, the admin screens, the settings, the scheduled jobs and the components a theme page attaches to. The theme holds markup and nothing else.

Money is stored as integer cents throughout and formatted in exactly one place — a registered eurocents list column type — so lists sort on the integer and always display 1.234,56 €. Prices are gross and VAT is captured on the order at checkout, which is how a Slovenian shop has to work.

Anything a listing needs to sort or filter on is denormalised onto the product: the rating, the sales count and the effective price range after sale prices and discount rules. The archive never touches the variation table.

What is inside

Components

What a theme page attaches to. Every one of them ships a default markup file the theme is free to override.

  • productList — the shop archive: filtering, sorting, paging
  • productPage — one product, its variations and its reviews
  • cart — the basket, attached in the layout so every page can read it
  • checkout — address, delivery, payment method, order placement
  • orderConfirmation — the thank-you page, addressable by order number
  • account — sign in, register, reset, details, order history
  • newsletter — the footer signup
  • selection — upsells, sidebar and brand blocks, wherever a selection rule puts them
  • paymentPage — the online payment step, beside Responsiv.Pay's payment component

Admin screens

Backend controllers, grouped into three main menu items: Catalogue, Orders and Customers.

  • Products, Categories, Tags, Attributes, Brands
  • Reviews — with a counter for the ones held for approval
  • Discount rules
  • Selection rules — merchandising placements, drag to reorder
  • Orders and Analytics
  • Customers and Subscribers

Settings

Settings models, so none of them needs a table or a migration.

  • Payment — bank transfer, cash on delivery, and online (card, PayPal) through Responsiv.Pay
  • Shipping — methods, rates and the free delivery threshold
  • Fees — handling, small order and payment surcharges
  • VAT — the rate contained in the gross prices

Domain classes

The logic that would otherwise sprawl across components.

  • CartManager — cookie-backed cart tokens with their own expiry
  • CustomerAuth — authentication and throttling
  • DiscountEngine — resolves catalogue rules onto effective prices
  • SelectionEngine — resolves a placement into blocks of products
  • Money — integer cents in, formatted euro out
  • OrderMailer — the four transactional emails
  • OnlinePayment — hands an order to Responsiv.Pay and marks it paid when the gateway confirms
  • PaymentTypes\StripeCardPayment — a Responsiv.Pay driver: a bare card form in the theme's design (Payment Element)
  • PaymentTypes\StripeEmbeddedPayment — a Responsiv.Pay driver: Stripe Checkout embedded in the page
  • SearchApi — backs the header search endpoint
  • VariationGenerator — builds variations from attribute values
  • Traits\HasProductTargets — the one answer to "which products does this rule mean?", shared by discount and selection rules

Merchandising

One rule fills one block. Several rules can share a placement — they run in order and nothing repeats between them, so a specific rule sits above a general fallback.

  • Placements — product page, basket and side cart, shop sidebar, brand page
  • Targets — the discount rules' vocabulary, plus same brand and same category relative to the product being viewed
  • Ordering — best selling, best rated, newest, cheapest, dearest, random or by name, on the price a customer actually pays
  • Skips what is already in front of the customer: the product being viewed, or whatever is already in the basket

Runs on a schedule

  • forge:shop:prune-carts nightly at 03:20 — the job WooCommerce runs as woocommerce_cleanup_sessions
  • Hourly reprice, so a discount rule's date window opens and closes without anyone pressing save

Routes outside the CMS

  • GET /forge-shop/search — throttled at 120/min per IP. A plugin route skips the theme, Twig and the layout entirely, which is what makes it cheap enough to call every few keystrokes.

See it running

Every link below is this site, not a screenshot.

  • Shop archive productList — filters, sorting and paging over the catalogue
  • A product productPage — pick anything off the archive to see variations and reviews
  • Basket cart — the same component the header count and the side cart read
  • Checkout checkout — delivery, payment method and VAT capture
  • Account account — sign in, details, saved address, order history
  • Producers The Brand model queried straight from a theme page
  • A producer page Pick any producer — /brand/:slug, with a rule-driven shelf under it

Changelog

[1.9.0] — 2026-10-22

Added

  • Size guide per category. Catalogue › Categories › Size guide, a rich-text chart. A product shows the guide of its most specific category that has one, or of the nearest parent category that does (Product::size_guide) — one chart on Oblačila covers every clothing subcategory.
  • The 30-day lowest price beside a sale (EU Omnibus). The shop keeps one row a day per product with the lowest price a customer could pay that day (forge_shop_price_history, written whenever the product's prices are recalculated). Product::lowest_30_days is the lowest price in the 30 days before today; the theme shows it under a sale price.

Fixed

  • The "Sale" chip on a variable product was English on every site; it is a plugin string now (catalogue.sale — "Akcija" in Slovene).

[1.8.0] — 2026-10-22

Added

  • Back in stock. A sold-out product, or a product with sold-out sizes, takes an email address on its page (productPage::onRequestStock, one request per address and size). When the stock comes back from 0 — a simple product, or the size — the shop emails each waiting address once (back_in_stock mail, en and sl, editable under Mail templates) and marks it. Catalogue › Back in stock lists who is waiting for what.
  • tests/stock-requests.php (refuses to run unless the mail driver is log).

[1.7.1] — 2026-10-22

Added

  • productList property defaultSort — the sort a page opens with (the shop page sets it from a theme option); links that keep the sort compare against it.

[1.7.0] — 2026-10-22

Added

  • A category's photo (Catalogue › Categories) — heads its page in the theme.
  • Categories as badges. A category with a badge label ("Bestseller", "Premium") and a colour (accent or dark) puts that label on the card of every product in it: Product::badges.

Changed

  • REST API orders follow the new checkout fields. An order now has a billing object (name, company, street, house number, postcode, city, country, phone, email) and a shipping object for where the parcel goes: the separate delivery address when ship_to_different is true, a copy of billing otherwise. Before, shipping carried the billing columns. Orders also return discount_total, the named discount_lines, coupon_code, delivery_slot (value and label), newsletter_opt_in and date_terms_accepted, and each line item returns its group_key. The same JSON goes out in order.* webhooks.
  • PUT /orders/{id} takes billing and shipping. Writing shipping switches on ship_to_different, and fields that are left out start from the billing address. ship_to_different: false goes back to delivering to the billing address. Top-level email is now billing.email.

[1.6.0] — 2026-10-22

Added

  • Delivery methods beyond the two built in. Settings › Shop › Shipping › More methods: any number more (a parcel locker, a try-on visit), each with a cost, a line under it at checkout, whether the free-delivery amount makes it free, and whether the customer books a time.
  • Appointments. Collection and any added method can take a booking: allowed weekdays, hours, how many days ahead at the earliest, how many days bookable. The checkout shows a day and an hour under the chosen method; the order keeps them (delivery_slot, delivery_slot_label), shown in the admin, on the thank-you and account pages and in the confirmation email.
  • A line under standard delivery and collection at checkout (standard_description, pickup_description).
  • tests/delivery-methods.php.

[1.5.0] — 2026-10-22

Added

  • Selection rules can carry a discount (percent_off). A product added from the rule's block becomes a line offer — the regular price less the percent, only when that beats its own sale price; individual, so it holds on its own. SelectionRule::dealCents(), dealPrice(), wasPrice() price the block the way the basket will. cart::onAddToCart takes selection_rule and checks it: live, with a percent, and reaching the product.
  • "Checkout — hidden offer" placement: one product in a block of its own at checkout.
  • cart::onAddToCart takes a size from a merchandising row as offer_variation[product id].
  • tests/selection-offers.php.

[1.4.0] — 2026-10-22

Added

  • Coupons — Catalogue › Coupons: a code the customer types at checkout ("BELOVED11"). Percent off each matching line, or a fixed amount off the basket once; the same targeting as discount rules; leave out items already discounted; a minimum basket; a date window; a usage limit. Codes are stored and matched in capitals.
  • Taken off as a discount line, so it shows by name in every total, on the order and the invoice. The order keeps the code (coupon_code) and the use is counted.
  • CartManager::applyCoupon() / removeCoupon(), onApplyCoupon / onRemoveCoupon on the cart and checkout components. A code that stops applying (an item removed) stays and says why: Cart::coupon_problem.
  • tests/coupons.php.

[1.3.0] — 2026-10-22

Added

  • Settings › Shop › Checkout: house number in its own field, a company name, "Deliver to a different address", a required terms box, a newsletter box, a guarantee line.
  • Orders keep company, house_number, the different delivery address (ship_*), when the terms were accepted, and whether the newsletter was asked for; ticking it subscribes the address. Order::street and Order::delivery_address for display — the shipped email now shows where the parcel actually goes.
  • Cart::regular_subtotal for "Regular price / Your price".
  • tests/checkout-fields.php.

Fixed

  • Checkout said delivery was "Free" until a method was clicked. The first method was ticked on screen but not saved on the basket, so the summary showed 0 and the total left delivery out. Cart::effective_shipping_method / effective_payment_method — the chosen one, or the first — price the summary and the order; a payment fee (cash on delivery) shows before a click too.

    [1.2.0] — 2026-10-22

Added

  • Free-shipping bar data on the basket, for the theme's cart/free-shipping-bar partial: free_shipping_threshold_cents, free_shipping_left_cents, free_shipping_progress (0–100) and has_free_shipping, with formatted free_shipping_left and free_shipping_threshold. Measured on the net subtotal — the figure delivery is charged on — so the bar never says "free" while checkout charges. The threshold is the existing Settings › Shop › Shipping › free delivery over; 0 means no bar.

    [1.1.1] — 2026-10-21

Added

  • CartItem::offer_waiting_note — a parent-mode offer whose parent product is not in the basket says what would unlock it: "Add Aidana hlače črne for −15 %" (sl: "Dodaj … za −15 %"). Before, the line simply showed its own price, while the product page had shown the lower one, with nothing to explain the difference.

    [1.1.0] — 2026-10-21

Added

  • Line offers. A basket line can belong to a group (group_key — lines added together) and carry an offer (price_meta: source, rule, percent, mode, parent product, label). CartManager::add() takes them as a fourth argument; newGroupKey() and groupKeyForProduct() help a plugin form or join a group. The shop prices the line itself: the offer is the REGULAR price less its percent, and it replaces the line's own price (sale price, discount rules) only when it is lower — an upsell replaces a sale, it never stacks on one.
  • Two modes. parent: the offer holds while its parent product is in the same group; remove the parent and the line falls back to its own price, keeps its offer and re-forms the pair when the parent comes back (a plain line of the parent is adopted into the group). individual: holds on its own — added by an upsell button, it keeps the price.
  • One for one in parent mode: a group's parent units cover as many offered units; the rest of the line moves to a plain line at its own price.
  • CartItem::offer_cents, offer_applied, price_note (the offer's label while it prices the line). Order lines keep group_key, and price_meta when the offer was applied.
  • DISCOUNTS.md — how prices and discounts work, for anyone changing them.
  • tests/line-offers.php — every rule above, in a rolled-back transaction, on any site.

Changed

  • Prices are worked out after every add, update and remove, not only when the basket loads, so the response that shows a change already shows its price (repriceItems() became priceLines()).
  • A line merges only with one of the same product, size, group and offer; stock is checked across all lines of a product and size.

    [1.0.40] — 2026-10-21

Added

  • Cart::claimDiscountUnits($itemId, $wanted) for discount-line listeners: a piece in the basket can be discounted once. A listener claims the units it discounts and gets back how many were still free, so two plugins offering the same piece cheaper — a bundle and a recommendation — can never both take money off it.

    [1.0.39] — 2026-10-21

Added

  • Discount lines. Money off the basket that is not a product's own price — a bundle today, a coupon later — without the shop deciding any of it. Another plugin answers the forge.shop.cart.discountLines event with lines of code, label and cents off; the basket shows each line by name, takes the sum off the total, and caps it at the subtotal so no listener can make a basket negative. Nothing changes on a site where nothing listens.
  • Cart::discount_lines, discount_cents, net_subtotal_cents (and formatted discount, net_subtotal). Free delivery and the order-size fees are measured on the net subtotal — what the goods actually cost after the discount.
  • Orders keep the discount they were placed with: discount_cents and the named discount_lines, so an order still reads correctly after the rule that produced it changes or is deleted. Shown in the order admin, the confirmation email (en, sl).
  • The online-payment invoice carries each discount line as its own row, so the invoice lines still add up to the order total to the cent.
  • Language key cart.discount (Discount / Popust) for a line that brings no label of its own.

    [1.0.38] — 2026-10-21

Migration v1.0_38_webhooks.php — tables forge_shop_webhooks and forge_shop_webhook_deliveries.

Added

  • Webhooks under Settings › Shop › Webhooks. A webhook tells another app when an order or a product is created, updated or deleted (order.created … product.deleted). A change to a variation counts as product.updated for its product. Each delivery POSTs the record's REST API JSON (just {"id"} for a deletion), signed in X-Forge-Webhook-Signature as base64 HMAC-SHA256 of the raw body with the webhook's secret, the same scheme WooCommerce uses. The secret is encrypted at rest.
  • Sent after the response. Model events only note what changed. Payloads are built when the request ends, after the browser has its answer, so checkout never waits on a receiver, and an order's line items are already in its order.created payload. Each record is reported once per request: several saves are one update, and a record created in the request is reported as created. Deliveries go through the queue (DeliverWebhook), so a real queue driver moves them off the web process entirely.
  • Failure handling as in WooCommerce: a 5-second timeout, no redirects, no retries, and the webhook switches itself off after 5 failed deliveries in a row or a 410 Gone. Switching it back on resets the count.
  • Delivery log and Send test on the webhook's edit screen: the last 20 attempts with response code, time taken and the start of a failed response. Send test POSTs a signed webhook.ping. The log carries customer details, so it is pruned nightly after 30 days.
  • Delivery URLs must be HTTPS and must not resolve to a private or reserved address, checked when the webhook is saved and again at every delivery (relaxed on APP_ENV=local).

[1.0.37] — 2026-10-21

Migration v1.0_37_api_keys.php — table forge_shop_api_keys.

Added

  • REST API v1 at /api/v1 for products and orders, so accounting, warehouse and marketplace apps can read the catalogue, sync stock and prices, and pick up and fulfil orders. The full reference is in docs/api.md.
    • Products: list with filters (search, SKU across variations, status, type, stock, sale, category, tag, brand, created and modified dates), read, create, update, soft delete, and a batch of up to 100. Variations: list, read, update, and a batch update for stock syncs.
    • Orders: list with filters (status, customer, email, payment method, number, dates), read by ID or by order number, update the status and the customer's contact and delivery details, and a batch update. A status change runs the order's normal status machine, so paid and shipped still stamp their dates and email the customer, and cancelled puts the stock back. Orders cannot be created or deleted through the API, and their amounts and line items cannot be changed.
    • WooCommerce's conventions: money as decimal strings, ISO 8601 dates, X-Total, X-Total-Pages and Link paging headers, and {code, message, data.status} errors. Validation errors name the API's fields, not the columns behind them.
  • API keys under Settings › Shop › REST API, modelled on WooCommerce's consumer keys. A key is a fk_ key and an fs_ secret tied to a backend user, with Read, Write or Read/Write permission, an optional expiry date and an optional IP or CIDR allowlist. Both halves are stored as hashes and shown once, when the key is generated.
  • Request security. Keys are sent as HTTP Basic credentials over HTTPS only. Unlike Woo there is no OAuth 1.0a fallback for plain HTTP and no credentials in the query string. A key can do no more than its user: products need Manage products, orders Manage orders, and deleting the user disables the key. Limits are 300 requests a minute per key, and 10 failed authentications a minute per IP before that IP is locked out for a minute. Last use and IP are recorded at most once a minute.
  • Permission Manage REST API keys and webhooks (forge.shop.api).
  • OrderItem gained a variation relation, so API line items carry the variation's SKU.

[1.0.36] — 2026-10-21

Code only — no migration.

Fixed

  • Customer sign-in failed with "AJAX handler 'account::onSignIn' was not found". RainLab.User, installed as a dependency of Responsiv.Pay, registers its own component under the name account and loads after this plugin, so it took the name over and every account page ran RainLab's component instead of ours. The shop's account component is now registered as shopAccount. Pages declare it as [shopAccount account], which keeps the page alias account, so account::onSignIn and {{ account.… }} in templates are unchanged. A child theme that declares [account] itself must switch to [shopAccount account].

[1.0.35] — 2026-09-13

Code only — no migration.

Added

  • Language files. Every string the plugin shows a customer — flash messages, checkout validation labels, order status names, sort options, breadcrumb roots, "Free" — now comes from lang/<locale>/lang.php, with English and Slovene shipped. The frontend locale is the site's locale (Settings › Sites), which RainLab.Translate applies per request, so a Slovene site gets Slovene messages while the backend stays English.
  • Slovene mail templates (views/mail/*-sl.htm), picked by RainLab.Translate's language suffix rule for the customer's locale.
  • Named fees. FeeSettings::linesFor() lists each applicable fee with its own label — handling, small order (under :amount), cash on delivery — and Cart::fee_lines exposes them for the basket and checkout, replacing the anonymous "Payment fee" row.

[1.0.34] — 2026-09-12

Code only — no migration.

Added

  • PayPal buttons on the checkout page. With the PayPal option selected, PayPal's own buttons render under it instead of the Place order button. Clicking one validates the form, places the order, and creates and captures the PayPal order through Responsiv.Pay's endpoints; the capture marks the order paid and the customer lands on the confirmation. checkout.paypalForm feeds the buttons; onPlaceOrder answers pay_inline=paypal with the invoice hash and the confirmation URL. The payment page remains the fallback.

[1.0.33] — 2026-09-12

Code only — no migration.

Added

  • PayPal at checkout. A paypal payment method joins the list under Settings › Shop › Payment, offered when Responsiv.Pay has an enabled PayPal method. Place order sends the customer to the payment page opened on PayPal's buttons (Responsiv's own driver, rendered in place), and PayPal's confirmation marks the order paid like any other gateway. OnlinePayment::METHODS / isOnline() now cover both online codes.

Fixed

  • models/Settings/ renamed to models/settings/. October maps namespaces to lowercase directories; macOS hid the mismatch, Linux did not (Class Forge\Shop\Models\Settings\FeeSettings not found on the first live deploy).

[1.0.32] — 2026-09-12

Code only — no migration.

Added

  • Card fields on the checkout page. With a "Stripe (card form)" gateway enabled, choosing the online option at checkout reveals Stripe's card fields right under it. Place order validates the card, places the order, opens the PaymentIntent for the order total and confirms the payment — all on /checkout. Stripe returns the customer to the verification endpoint and on to the confirmation. checkout.cardForm feeds the deferred Payment Element (publishable key, basket total); onPlaceOrder returns the intent's client secret when the page asks for it (pay_inline) instead of redirecting. Any other online gateway, and a browser without the card element, still go to the payment page as before.
  • checkout.pendingOrder / pendingPaymentUrl — the last order this browser placed, while it waits for payment: the checkout's empty state offers Pay now, so a declined card followed by a reload is not a dead end.

[1.0.31] — 2026-09-12

Code only — no migration.

Added

  • Stripe card form — PaymentTypes\StripeCardPayment, a second Responsiv.Pay driver of our own: Stripe's Payment Element restricted to cards, so the payment page shows card number, expiry and CVC in the theme's own colours and type with the theme's own Pay button — nothing else. The fields are still Stripe's iframes (PCI scope stays with Stripe) and 3-D Secure is Stripe.js's. A PaymentIntent is opened server-side for the invoice amount and reused across reloads; the return access point re-fetches the intent with the secret key and checks status, amount, currency and invoice before marking it paid. Optional signed webhook for payment_intent.succeeded (/api_responsiv_pay/stripe_card_webhook/event) covers a customer who never returns. Needs secret and publishable keys; the webhook signing secret is optional.
  • OnlinePayment::invoiceFor preselects the card form first, then embedded checkout.

[1.0.30] — 2026-09-12

Code only — no migration.

Added

  • Stripe embedded — a Responsiv.Pay gateway driver of our own, PaymentTypes\StripeEmbeddedPayment, registered through registerPaymentGateways. Same Checkout Session API as Responsiv's hosted Stripe driver, opened in embedded mode, so the card form (and the wallets enabled in the Stripe dashboard) renders inside our payment page. Stripe still owns the form, 3-D Secure and PCI scope; the return trip is verified by the inherited handler before the invoice is marked paid. Needs the publishable key as well as the secret.
  • OnlinePayment::invoiceFor preselects the embedded card form when it is enabled, so the payment page opens straight on the card fields: place order → card form → confirmation.

Fixed

  • The order confirmation no longer shows payment instructions on a paid order, nor the online method's checkout note — the Pay now link is that method's next step.

[1.0.29] — 2026-09-12

Code only — no migration.

Added

  • Online payment — card and PayPal — through the Responsiv.Pay plugin. A third method, online, joins bank transfer and cash on delivery under Settings › Shop › Payment. An order that chooses it is placed on hold, gets a Responsiv.Pay invoice linked back to it — amounts copied line for line, never recomputed, shipping and fees as lines so the totals match to the cent — and the customer is sent straight to the public payment page. When the gateway confirms, responsiv.pay.invoicePaid moves the order to paid exactly as a hand-marked bank transfer does, so paid_at and the customer email follow. Nothing else in the shop knows a gateway exists: Classes\OnlinePayment is the whole bridge.
  • The plugin stays optional. PaymentSettings::available() offers online only when Responsiv.Pay is installed and at least one gateway is enabled under Payments › Payment Methods, whatever the switch says — an option that leads to a page with no way to pay is worse than no option.
  • paymentPage component for /payment/:hash: the order behind the invoice for the summary column, and a redirect to the confirmation page once the order is no longer waiting.
  • orderConfirmation.paymentUrl — the way back to the payment page while an online order is still unpaid (closed tab, cancelled gateway session).

Removed

  • The card placeholder payment method, which showed an option but took no payment.

[1.0.28] — 2026-09-09

Code only — no migration.

Changed

  • ProductList gained an allLabel property for the heading shown when no category is selected. It was hardcoded 'All products', which on a translated storefront surfaced as the <h1> of the busiest route in the shop.

[1.0.27] — 2026-09-09

Code only — no migration.

Added

  • {{ configurator.maxStep }} — the furthest step a customer may open, which is the first unanswered one. You can always go back over what you have decided and forward exactly one step at a time. A wizard that lets someone skip to packaging with no blade chosen is quoting a price for nothing and drawing a preview of nothing.
  • chooseFirstLabel property for the hint shown while a step is unanswered.

Changed

  • currentStep() clamps to maxStep(). The clamp is the enforcement, not the disabled button: a disabled control stops a click, but typing /sestavi-noz/5 is not a click, and without this the URL walks straight past every step the customer has not answered.

[1.0.26] — 2026-09-09

Fixed

  • A basket line could be added and the header still say zero. Every component built its own CartManager. In one request the layout's cart component read the basket to draw the header, the page's configurator then added an item through a different manager, and the header partial re-rendered in that same response from the first manager's cached basket. The knife was in the database and the counter said 0. CartManager::instance() is now one manager per request — mirroring CustomerAuth::instance(), already used the same way — so one component's change is visible to every other.

Changed

  • After add-to-cart the configurator returns to step 1 instead of sitting on the last step with every option blank and the total at zero, which read as the wizard having broken rather than an order having completed. {{ configurator.wasReset }} marks that response so the theme can put the address bar back to match.

[1.0.25] — 2026-09-08

Added

  • Composited configurator preview. Each option can carry a transparent PNG, and the preview stacks the chosen ones in paint order, so picking a blade and a handle draws the knife those two choices actually make instead of showing one fixed photograph whatever was chosen. Migration: v1.0_25_configurator_layers.php.
  • attributes.layer_order — WHERE a step paints. It belongs to the step, not the option, because every handle sits at the same depth whichever wood it is. NULL means the step contributes nothing to the picture (packaging, engraving). Lower paints first, so a blade at 10 sits behind a handle at 20 and the handle's bolster covers the tang, as on a real knife.
  • attribute_values.layer — WHAT that option looks like. An attachOne, kept separate from the existing image because they are different pictures: image is the thumbnail on the option card, cropped to look good small; layer is artwork on the product's shared canvas and usually looks like a fragment on its own.
  • {{ configurator.layers }} and {{ configurator.hasLayers }}.

Notes

  • Every layer of one product must be exported on the same canvas at the same size. That shared canvas is the only thing making the parts meet, and nothing in the plugin can enforce it — it is a rule for whoever draws the art. Registering the parts in the export pipeline rather than by hand is strongly preferable; see scripts/build-knife-layers.py in the Blacksmith site for a worked example that measures each part and moves it onto a fixed seam.
  • A step whose chosen option has no artwork is skipped rather than substituted. Half a knife reads as a missing picture; a knife wearing the wrong handle reads as a bug in the pricing.

[1.0.24] — 2026-09-08

Added

  • forge:shop:seed-demo — fills the catalogue with enough of a shop to test against. The original content had two products per leaf category, which is enough to prove a page renders and nothing else: related products showed one item, the archive never paged, and every filter returned the whole shelf. Now 50 products across 12 categories and 15 brands, with a second top-level branch (Cellar › Wine, Vinegar) so the nesting is exercised rather than assumed. Idempotent by slug — run it twice and nothing doubles, and hand-edited content survives a re-run.

Fixed

  • Child categories in the shop filters had no product count. filterCategories() put withCount on the top-level query only; children were lazily loaded without it, so each rendered a blank where its number should be. Invisible with two categories, obvious with twelve. Children are now eager-loaded with their own counts.

[1.0.23] — 2026-09-08

Changed

  • The product page has a Related products section again, built in rather than configured. Selection::defaultBlocks() builds it from the product's deepest category — something in "Pantry › Honey" relates to other honey, not to everything in the pantry. Four products, best selling first, the product being viewed excluded, and nothing at all rendered when the category has no siblings.
  • Removed the "More from this maker" block. Related means the same shelf, not the same producer; a customer looking at a prosciutto wants other cured meat, not the same farm's honey. The maker is still reachable — every product card names it and /brand/:slug lists everything they make.
  • The two selection rules that were only describing this default behaviour were deleted. Nobody should have to configure "show related products" — that is how a product page works, not a decision. The backend list now reads as offers only.
  • The block is still rendered through an unsaved SelectionRule, so it keeps every override point: data-selection="default_related" for CSS, and shop/selection/default_related.htm to replace its markup.

[1.0.22] — 2026-09-08

Code only — no migration. version.yaml carries a description line and no filename, which October accepts.

Added

  • Configurator::onStep() — moves the wizard between steps over AJAX instead of reloading the page.

  • Wizard wording is now component properties (railLabel, previewLabel, prevLabel, nextLabel, addLabel, vatLabel, missingLabel, shippingNote), exposed as {{ configurator.labels }}, with the trust lines read from a configurator_assurances theme option via {{ configurator.assurances }}.

    This is not tidying. A partial rendered for an AJAX response is rendered without the arguments the page passed it, so wording supplied as {% partial 'configurator/rail' vat_label='vklj. DDV' %} is correct on first paint and silently reverts to the English default on every interaction after that. On a translated site the interface flipped back to English the moment a customer touched it. Component properties are part of the page's own configuration and survive; theme options are read from the database and survive. Partial arguments do not.

    The general rule, worth remembering before making any partial AJAX-updatable: if a partial can be re-rendered by an AJAX response, nothing it displays may come from a partial argument.

Changed

  • Configurator::currentStep() prefers a posted step over the URL segment. During AJAX navigation the address bar has not been rewritten yet — the history entry is written after the response lands — so the request itself has to say which step it wants.
  • The step is deliberately not stored in the session. Which step is open is a property of the URL, not of the build, so two tabs can sit on different steps of the same configuration.

[1.0.21] — 2026-09-08

Added

  • code on selection rules — the handle a theme overrides by name. Set a code in the backend, add partials/shop/selection/<code>.htm to the theme, and that one rule renders through it instead of its placement's partial. checkout_gift and checkout-gift resolve to the same file; nobody should lose ten minutes to that. Resolution lives in SelectionRule::templatePartial() and checks the file actually exists, so setting a code changes nothing until someone creates the partial, and deleting the partial falls back rather than breaking the page. The code is also emitted as data-selection on the block, so CSS alone can target one named rule. Migration: v1.0_21_selection_codes.php.
  • checkout placement. Blocks render beside the order summary.

Note on numbering

v1.0.20 is deliberately unused. Two sessions claimed it within seconds of each other and October refused to migrate on the duplicate key. The gap is left rather than closed: renumbering a migration that may already have run somewhere is worse than a missing number. Rule adopted going forward — whoever has a migration file takes the lower number, because renaming v1.0_NN_*.php can strand an applied migration while renumbering a code-only entry costs one line.

[1.0.19] — 2026-09-08

Added

  • Selection rules — one rule fills one merchandising block: product-page upsells, basket and side-cart upsells, a shop-sidebar block and brand-page shelves. Each rule says where it shows, which products it means, how many and in what order, and between which dates. Migration: v1.0_19_selection_rules.php.
  • Classes\SelectionEngine resolves a placement into blocks. Several rules can share a placement; they run in sort_order and anything shown by a rule higher up is dropped from every rule below it, so a specific rule can sit above a general fallback without the two repeating.
  • selection component, Catalogue → Selection rules admin screen with drag-to-reorder, and a forge.shop.merchandising permission.
  • Two targets that only selection rules offer, because they read from the product being viewed: same brand and same category. With no product in context they match nothing rather than falling back to the whole catalogue.

Changed

  • Targeting is now shared. The "which products does this rule mean?" logic moved out of DiscountEngine into Models\Traits\HasProductTargets, used by both DiscountRule and SelectionRule. It answers in two shapes: matchesProduct() tests one product without touching the database (the discount sweep calls it thousands of times), and targetQuery() returns a query so merchandising can sort and limit in SQL. DiscountEngine::matches() remains as the engine's entry point and delegates. No behaviour change — verified against the live catalogue before and after.
  • DiscountRule lost its own copies of scopeActive, getIsLiveNowAttribute and getScopeLabelAttribute; all three now come from the trait.

[1.0.18] — 2026-09-08

Added

  • Configurable products — a made-to-order product assembled step by step from global attribute values, each carrying its own price change. Migration: v1.0_18_configurator.php.

    Why not variations. A knife with 6 blade types × 4 steels × 8 handle woods × 3 finishes × 3 packagings is 1,728 sellable rows, none of them ever stocked, all of them generated up front and then scrolled past in the backend. WooCommerce hits the same wall and answers it with add-on plugins. Here the combination is computed when the item is added to the basket and stored on the basket line, so the catalogue keeps exactly one product row.

    The options stay ordinary AttributeValue records, so the configurator and the shop filters speak one vocabulary: "Handle: Walnut" filters the archive and prices a configuration with the same record.

  • price_delta_cents (signed) and description on attribute values, plus an optional image attachment — the blade silhouette, the wood grain, the gift box. A delta of zero prints as "+0 €" rather than being hidden: someone scanning a step needs to see that an option is free, not infer it from an absence.

  • is_configurator on a product's attribute rows. That is the third role an attribute can play, beside "describes the product" and "drives variations": "is a step in the wizard". is_configurable on the product itself.

  • Classes\ConfiguratorEngine — the only place that decides what a configured product costs. steps() describes the wizard; resolve() validates a selection and prices it; signature() gives a configuration its identity. The browser posts value ids and nothing else — never a price, never a label — so a tampered form can at worst choose a different legal option. A value from the wrong attribute, or one this product does not offer, is rejected.

  • Components\Configurator — holds the selection in the session rather than the URL or a hidden field, so a customer who wanders to a product page and comes back still has their half-built item; the URL carries only which step is open, so a step is linkable and the back button behaves. Choosing an option deliberately does not advance the step: someone comparing two handles should be able to click between them and watch the price change without the page moving.

  • CartManager::addConfigured(). Two configurations of one product are two basket lines unless every chosen value matches — the same rule two variations follow. The match runs in PHP over that basket's own lines, since no SQL predicate compares a JSON configuration portably and the set is small enough that it is cheaper anyway.

  • config JSON on cart items and order items, denormalised at the moment it was added: [{attribute, value, value_id, delta_cents, delta}, …]. Denormalised on purpose — a later rename of an attribute value must not silently rewrite what someone already has in their basket, or what an order says was bought.

Changed

  • CartManager::repriceItems() skips configured lines. A configured line is a base plus option deltas; repricing it from the catalogue alone would silently drop every delta the customer chose.
  • CartItem::regular_unit_cents returns the paid price for a configured line, so no meaningless strike-through appears — the base may be on sale, the deltas are not, and comparing the two says nothing.
  • Merging a guest basket into an account now compares configuration signatures as well as product and variation, so two different made-to-order builds stay apart.

[1.0.17]

Added

  • Newsletter subscribers behind the footer signup, with a Subscriber model and a backend list under Customers. Migration: v1.0_17_subscribers.php.

[1.0.16]

Added

  • Discount rules — bulk catalogue discounts targeted by product, category, tag, brand or attribute value, resolved by Classes\DiscountEngine. Migration: v1.0_16_discount_rules.php.
  • Hourly forge-shop-reprice schedule, so a rule's date window opens and closes without anyone pressing save.

[1.0.15]

Added

  • Meta title and description on products and categories. Migration: v1.0_15_seo_meta.php.

[1.0.14]

Added

  • Sale prices on products and variations, with a denormalised effective price range so the archive can sort and filter on the price a customer actually pays. Migration: v1.0_14_sale_prices.php.

[1.0.13]

Added

  • Order lifecycle with per-payment-method entry statuses, VAT capture at checkout, transactional email (views/mail/), customer password reset and product photos. Migration: v1.0_13_order_lifecycle_vat.php.

[1.0.12]

Added

  • Fulltext search index and the /forge-shop/search endpoint behind the header search (Classes\SearchApi). Migration: v1.0_12_search_index.php.

[1.0.11]

Added

  • Product reviews with photos, feeding the denormalised product rating. Migration: v1.0_11_reviews.php.

[1.0.10]

Added

  • Saved delivery address and preferred payment method on customers. Migration: v1.0_10_customer_delivery.php.

[1.0.9]

Added

  • Archive filtering and sorting, backed by denormalised rating and sales columns. Migration: v1.0_9_shop_browsing.php.

[1.0.8]

Fixed

  • Value rows managed by a repeater could fail to save because their columns were not nullable. Migration: v1.0_8_repeater_tolerance.php.

[1.0.7]

Added

  • Swatch display types on attributes and per-variation stock management.

Changed

  • Variation pricing is denormalised onto the product so listings do not need to touch the variation table. Migration: v1.0_7_variation_ux.php.

[1.0.6]

Added

  • Simple and variable products, with variations generated by Classes\VariationGenerator. Migration: v1.0_6_variations.php.

[1.0.5]

Added

  • Categories, tags, brands and attributes, plus usernames on customers. Migration: v1.0_5_taxonomy.php.

[1.0.4]

Added

  • Customer accounts, authentication and throttling (Classes\CustomerAuth). Migration: v1.0_4_customers.php.

[1.0.3]

Added

  • Cookie-backed cart tokens with their own expiry, so a basket survives a session but not forever. Migration: v1.0_3_cart_tokens.php.
  • forge:shop:prune-carts console command, scheduled nightly at 03:20.

[1.0.2]

Added

  • Carts and orders. Migrations: create_carts_table.php, create_orders_table.php.

[1.0.1]

Added

  • First version of Shop — the products table and the plugin skeleton. Migration: create_products_table.php.