@nextgen-composable/next-gen-composable
v1.0.5
Published
Reusable storefront composables (catalog / cart / shipping / billing / payment / configurator): executor-based transport behind a host facade
Readme
@nextgen-composable/next-gen-composable
Foundry-30846 — reusable catalog, cart, shipping, billing, payment and configurator composables on one executor-based RuntimeContext contract. Consumers: HMH (ship-to + bill-to), Homewater (install/service address across Retail, D2D, Direct).
v1.0 — the facade cutover (breaking). The packages no longer talk to
Salesforce. Every adapter sends (method, payload) through a host-injected
executor (context.executor); the host's server-side facade owns the org URL,
the credential, the operation allow-list, and the account scoping. The old
contract — context.security.{baseUrl, tokenProvider} with a browser-held
Bearer token calling apexrest directly — is gone and can no longer be
expressed. The reference facade lives in packages/facade/; the
governing docs are Composable Drop-in Patterns v2.0 (the seam) and
Genesis Platform API — Target Standard v1.0 §5 (sanctioned interfaces).
Workspace layout
The repo is an npm workspace organized by composable — each package is independently buildable/testable and designed to be extracted into its own repository later (see docs/extracting-a-package.md):
packages/
composable-runtime/ @nextgen-composable/runtime — RuntimeContext contract (shim):
ComposableExecutor, executorOf/scopeOf, ServiceStatus/MutationResult,
framework-neutral store, formatPrice, createMockRuntimeContext,
and the shared address kernel (AddressDTO + normalize/validate)
catalog/ @nextgen-composable/catalog (owns CatalogCartGateway — its view of the cart)
cart/ @nextgen-composable/cart
shipping/ billing/ payment/ configurator/
facade/ @nextgen-composable/facade — HOST-OWNED, PUBLISHED: the
server-side facade kernel (per-family allow-list manifests,
org auth, scope stamping, Blink wire quirks) + the browser
executor. Imports no composable package; no composable
package imports it. Hosts install it from the root package
(`/facade`, `/facade/client`) and mount it in their own
server — see [packages/facade/README.md](./packages/facade/README.md)
src/ the published package's compatibility facade + subpath entriesEvery composable package has the same internal shape:
packages/<name>/src/
core/ DTOs, service ports, config, offline validation — provider-neutral
controller/ framework-free state machine
react/ <Name>Panel (provider-neutral, takes `service`) +
Connected<Name>Panel (batteries-included, executor-wired) + use<Name>Controller
adapters/ create<Name>Service — payload building + response mapping; every
call goes through context.executor. NO fetch anywhere (guard-enforced).
testing/ createMock*Service — complete in-memory services, run e2e with no backend
styles.css the composable's class-scoped --ec-* styles
index.ts the package's public API (nothing else is public)Allowed dependencies between packages are documented in
docs/dependency-boundaries.md and enforced by
tests/architecture-guard.test.ts.
The transport contract (one law)
Browser Host server Salesforce
ConnectedCatalogPanel
└─ adapter: executor('retrieveCatalogNG', {…})
└─ POST /api/composables ────────▶ facade
├─ allow-list the operation
├─ stamp the account scope
├─ mint/cache the org session
└─▶ apexrest ────────────────────▶ org- The host builds ONE context:
{ runtime: 'custom', identity, session, executor }. executoris a ten-line POST to the host's own backend (createFacadeExecutorin packages/facade/src/client.mjs).- No org URL, token, or credential exists in the browser. The facade's
allow-list (packages/facade/src/ops.mjs, one
manifest per family under
ops/) is the entire reachable API. - Quote resolution stays host-owned: the host calls
executor('getActiveQuote', …)itself and passessession.quoteIdin.
Supported imports
// Historical surface (panels bind to the executor-wired Connected flavors):
import { CatalogPanel, CartPanel } from '@nextgen-composable/next-gen-composable'
import '@nextgen-composable/next-gen-composable/styles.css'
// Per-composable subpaths — pull in one composable only:
import { ConnectedCatalogPanel, CatalogPanel } from '@nextgen-composable/next-gen-composable/catalog'
import '@nextgen-composable/next-gen-composable/catalog/styles.css'
// (same for /cart, /shipping, /billing, /payment, /configurator — the panel-
// shipping ones also expose the provider-neutral base panel, which takes an
// explicit `service`)
// Inside this repo (and after extraction): the workspace packages themselves:
import { CartPanel } from '@nextgen-composable/cart'
import '@nextgen-composable/cart/styles.css'Per-composable integration guides
Every composable carries its own INTEGRATION.md — the same eight sections in the
same order, so an integrator opens any package and finds the wiring contract in the
same place: install, runtime context (which fields this composable needs and
why), config, events the host must handle, styling (tokens → theme →
classes → replace), headless, zero-backend mocks, and a preflight checklist.
| Composable | Guide | Ships a panel? |
| --- | --- | --- |
| catalog | INTEGRATION.md · config reference | yes |
| cart | INTEGRATION.md · config reference | yes |
| configurator | INTEGRATION.md | yes, plus a <vue-expedite-configurator> custom element |
| shipping | INTEGRATION.md | no — headless only |
| billing | INTEGRATION.md | no — headless only |
| payment | INTEGRATION.md | yes (PaymentPanel takes your controller) |
Family-wide headless answer, with the one packaging gap named: docs/headless-support.md.
Usage
For a client-shareable setup guide, see CLIENT-INTEGRATION.md.
Address capture
There is no address composable and no address panel. Capturing an address is always part of a step that owns it, so the address book, validation and save live in shipping (ship-to) and billing (bill-to) — both headless, so the host renders its own form:
import { useShippingController, createShippingService } from '@nextgen-composable/next-gen-composable/shipping'
// Shared address kernel — the DTO both steps speak, plus offline validation:
import { emptyAddress, validateAddressFormat, formatAddressLine } from '@nextgen-composable/next-gen-composable'
const shipping = useShippingController({
context: runtimeContext, // host supplies identity + executor (its facade)
service: createShippingService({}),
config: shippingConfig,
quoteSource: { getShippingAddress: () => quote.shippingAddress ?? null, /* … */ },
callbacks: { onAddressSaved: (address) => track(formatAddressLine(address)) },
})See demos/demo/checkout for the reference form + address-book + modal built on that controller.
Checkout page
There is no checkout composable either — a checkout page is a host
composition of shipping + billing + payment. Each composable owns one step and
stays stateless about the quote; the host reads the quote and feeds each one
through its quoteSource gateway. demos/demo/checkout
is the reference composition, end to end.
Catalog page
import { CatalogPanel } from '@nextgen-composable/next-gen-composable'
<CatalogPanel
context={runtimeContext} // identity.accountId + executor drive every platform call
onEvent={(event) => { // one sink; switch on event.action.name
if (event.action.name === 'addedToCart') rememberQuote(event.action.details.result.quoteId)
}}
/>- Executor transport: panels read
context.executor,context.identity.accountId, andcontext.session.quoteId; the host's facade makes the org call. - Look: replicates blink's catalog page 1:1 (
MoleculeProductBlock→MoleculeSearch/MoleculeProductItem/MoleculePagination): "Showing N results" header, bordered search bar, product cards with image · text · attribute fields · action button, numbered pagination. - Layouts:
config.catalogDisplay.orientation—'horizontal'(default) = blink's horizontal card orientation (full-width rows, image left);'grid'= blink's vertical cards in a wrapping grid, on the corrected card design (fixed 4:3 media box, two-column meta grid, one primary action per card with an overflow menu);'service'= the same card with no hero, for catalogs of software/services; also'square'and'table'. - Config-driven queries:
CatalogConfigcontrols catalog source, inactive-item handling, fixed filters, multi-select left-rail facets, initial/two-level sorting, result counts, cart visibility, card fields, and paging. Existing Blink block JSON can be normalized withmapBlinkCatalogConfig. - Currency-aware: prices use each Salesforce record's
CurrencyIsoCode, falling back tocontext.identity.currencyCodewhen the record omits it. - Real cart lifecycle: the add response's quote-line id is retained, so decrementing a
quantity to zero sends
deleteLineItemsbefore the counter disappears. - Degraded, never blank: a provider outage keeps the last successful page on screen with a Retry action — an outage degrades browsing, it never empties the page.
- Headless usage:
createCatalogController(no React) oruseCatalogController(React, custom UI).
Cart page
import { CartPanel } from '@nextgen-composable/next-gen-composable'
<CartPanel
context={runtimeContext} // session.quoteId identifies the active cart
onEvent={(event) => { // one sink; switch on event.action.name
if (event.action.name === 'cartChanged') syncCartBadge(event.action.details.cart.lines.length)
}}
/>- Cart operations: load cart lines, update quantity, remove lines, apply/remove promo code, and render quote totals.
- Headless usage:
createCartController(no React) oruseCartController(React, custom UI).
Contract highlights (from the Foundry build notes)
- Service port, RuntimeContext first:
searchAddresses,resolveAddress,validateAddress,listSavedAddresses(account-scoped; empty for guests). - Host owns persistence — the composable emits a normalized DTO via callbacks, nothing more.
- Google Places stays behind the host BFF (
POST /api/v1/platform/address:search); no vendor SDK is embedded and no key ever reaches the client bundle. - Deterministic fallback: provider outage → manual entry with offline format validation (US ZIP / CA postal matrix). Never blocks checkout.
- Mutations resolve to
{ status, errorMessage }— they never throw. - Controlled inputs only; class-scoped
--ec-*tokens, dark theme via[data-ec-theme='dark']; no Shadow DOM. Peer React^18 || ^19, ESM dist with./styles.cssexport. - Architecture-guard test enforces composable boundaries, per-package layering, provider
neutrality, bans direct transport everywhere (no fetch/XHR/axios in any package —
adapters call the injected executor only), and keeps org coordinates and credentials out
of the runtime contract (
tests/architecture-guard.test.ts).
Running the demo
demos/demo/.env.local SALESFORCE_BASE_URL + SALESFORCE_CLIENT_ID/SECRET (or SALESFORCE_TOKEN)
+ EC_ACCOUNT_ID — server-side names, read only by the facade.
VITE_SF_* keeps only browser-visible identity/display facts.
npm run dev vite dev server; @nextgen-composable/facade/vite hosts POST /api/composablesScripts
npm run typecheck— strict TS for the facade + every workspace packagenpm test— vitest (all package tests + the architecture guard + the facade tests); or per package:npm test -w @nextgen-composable/cartnpm run build— ESM bundle (root + per-composable entries),.d.tstree (workspace specifiers rewritten so the tarball is self-contained), anddist/styles.css+dist/styles/<composable>.css
TODO before promotion
- [ ] Swap
packages/composable-runtime/src/runtime.tsshim for@expedite-commerce/composable-coreonce 30845 ships; replace the hand-rolled architecture guard with core's reusable helper. - [ ] Extract the architecture guard as a shared dev-dependency the sibling composable repos import (so the facade rule is not re-derived per repo).
- [ ] Google Places key per environment (open item — who provisions).
- [ ] Confirm the US-vs-Canada postal matrix with Homewater; extend
core/validation.tsif more countries land. - [ ] Copy
docs/promotion-pack.md+ a typed client intoec-foundry-common/api-clients/address/.
