@apextelemed/partner-core
v0.14.0
Published
Shared frontend library for telehealth patient portals (api client, stores, headless flows).
Readme
@apextelemed/partner-core
Shared library for the telehealth patient-portal sites (perihelion, axiom-health, sculpted-sciences). Owns the API client, persistent stores, and — over time — headless flow hooks that let each site render its own UI on top of a shared state machine.
The local checkout for this repo lives at
the-frontend/for historical reasons; the published package name is@apextelemed/partner-core.
Documentation
Consumer documentation lives on the Apex Telemed developer docs site, next to the Partner API and survey helper docs:
https://thefortifiedgrp.github.io/apex-devdocs/partner-sdk/
- Overview and tiers
- Installation and configuration
- API client and stores
- Flow hooks
- Pages and primitives
- Theming
- Testing your site
- Gotchas
Those pages are generated from this package's sources and are the reference for consumers. The rest of this README is maintainer material: workspace layout, publishing, the export map, and local development. Keep the site in sync when the public surface changes; its source is the public apex-devdocs repo.
Agent guidance
The Claude skill for this package now lives in the shared plugin repo
(claude-kobayashi, skill apex-partner-core) rather than in .claude/skills/
here. It moved because the consumer sites are the audience that needs it most,
and a repo-local skill only loads for someone sitting in this checkout.
/plugin marketplace add thefortifiedgrp/claude-kobayashi
/plugin install systems@kobayashiEdit it there — porting a site, changing the package, the landmine list, and the public-surface reference all live in that skill.
A PR that changes this package's public surface updates that plugin in the
same PR. The exports, the Primitives interface, the PortalConfig shape and
the flow hook signatures are consumed by every site, and the plugin's
public-surface.md is what the consumer sites read instead of this repo. A
stale surface reference is worse than none — it gets believed.
What's in here
src/
config.ts configure({ apiBase, tenantKey, storagePrefix })
api/
client.ts apiFetch + namespaced API modules (auth, payments, subs, ...)
stores/
auth.ts authStore singleton (login, signup, logout, profile)
cart.ts cartStore singleton (items, promo, persistence)
medication.ts medicationStore singleton (selected drug)
flows/ headless hooks for survey/checkout/signup/dashboard/etc.
pages/ Tier 3 themed page components
primitives.tsx the Primitives contract sites implement
theme/ CSS-variable stylesheets + UnoCSS preset
testing/ the seam suite consumers run against their own siteConsumer setup
Sites must call configure() once at boot, before any store or API import is touched:
// src/index.tsx
import { configure } from '@apextelemed/partner-core/config';
configure({
apiBase: import.meta.env.VITE_API_BASE_URL ?? '/api',
tenantKey: import.meta.env.VITE_TENANT_KEY ?? 'perihelion-dev-key',
storagePrefix: 'perihelion', // unique per site
});
// then mount the appImports use subpath exports:
import { authStore, cartStore } from '@apextelemed/partner-core/stores';
import { paymentsApi, subscriptionsApi, type User } from '@apextelemed/partner-core/api';Distribution
Published to npm as a public package:
npm install @apextelemed/partner-coreCut a release by bumping version in package.json, merging, then pushing a
matching tag: git tag v0.1.0 && git push --tags. .github/workflows/release.yml
builds, runs the guards, and publishes. A tag that is not on main is refused
before any build runs.
Auth is npm trusted publishing (OIDC) — no token. The publisher is configured
for this package on npmjs.com and matched on the workflow's filename, so
renaming release.yml breaks publishing until that config is updated. There is
deliberately no token fallback: npm prefers a token whenever one is set and
never attempts the OIDC exchange, so a fallback would mean trusted publishing
was never actually exercised.
The dev channel
Every push to dev also publishes, from the same workflow, a pre-release of
the next version under the npm dist-tag dev:
npm install @apextelemed/partner-core@dev # e.g. 0.10.0-dev.57It is the same packed tarball a release ships, with the same guards run on it, so a dev site exercises the real packaging before anything is released.
latestnever moves on a dev publish. A pre-release sorts below its base (0.10.0-dev.57 < 0.10.0), so@latestand ordinary ranges never resolve to one; the workflow also checkslatestafterwards and fails if it changed.- The tag release refuses any pre-release version, so a
-dev.Nbuild cannot be published aslatestby tagging it. - The version is computed in CI (
scripts/dev-version.mjs) and never committed:dev'spackage.jsonkeeps the last released version until the release bump. - A site that wants a moving dev build installs
@devin its dev deploy only; a site that pins installs an exactX.Y.Z-dev.N. Production builds stay onlatestor a released pin. - Dev builds are public on npm like releases are. Don't push anything to
devthat must stay private until release.
provenance is off while this repo is private; turn it back on in
publishConfig once it is public. 0.1.0 has no attestation and cannot be
given one retroactively.
License
Source-available, not open source. The Apex Telemed Platform Integration
License in LICENSE lets you use, modify, and redistribute the package
however you like — as long as it is for building and operating a site that
integrates with Apex Telemed's platform on behalf of an Apex Telemed customer.
Any other use needs a separate license. There is no conversion to an open
source license. package.json declares SEE LICENSE IN LICENSE, so
dependency scanners will show it as a custom license; that is expected.
What ships, and why the export map looks like that
Only dist/ is published (files: ["dist", "README.md", "LICENSE"]), with
sourcemaps. src/ is not in the tarball.
Each entry is exported under several conditions:
"./pages": {
"apex-src": "./src/pages/index.tsx",
"solid": "./dist/pages/index.jsx",
"import": { "types": "./dist/pages/index.d.ts", "default": "./dist/pages/index.js" }
}solid— JSX with types stripped but the JSX not compiled.vite-plugin-solidenables this condition and compiles it in the consumer's app, which is the best output: correct reactivity, real HMR, one Solid runtime.import— the same code compiled withbabel-preset-solid, for anyone resolving without that plugin (SSR, vitest, other bundlers).apex-src— a private condition used only inside this workspace; see "Local development".
Shipping raw .tsx and letting consumers compile it — which is what the
file: symlinks in this workspace effectively do — is not an option for a real
install. Vite pre-bundles dependencies with esbuild, and esbuild cannot perform
Solid's compile-time JSX transform; components render once and never update.
Every entry in build.entries.mjs must be .tsx, including the ones with no
JSX in them. tsup-preset-solid emits the solid condition only for .tsx
entries and builds those in a separate tsup invocation, i.e. a separate chunk
graph. Mix the two and a consumer resolving through solid draws pages from
one graph and config/stores from the other — two copies of modules whose
whole design is that there is one: configure() writes a current the stores
never read, and the host app's chrome watches a different authStore than the
package's pages do. Nothing throws. tests/published/singleton.test.mjs guards it,
and npm run smoke:pack proves the real tarball resolves correctly by
installing it into a throwaway Vite app.
package.json's exports is generated — edit build.entries.mjs and run
npm run gen:exports. CI fails if the two drift.
Local development
Sites in this workspace still link the package by path
("@apextelemed/partner-core": "file:../the-frontend"). Because the
export map now points at dist/, a link alone would mean editing core requires
a rebuild before the site sees it.
The apex-src condition restores instant HMR. In the consumer site's
vite.config.ts:
resolve: {
conditions: ['apex-src'],
dedupe: ['solid-js'],
}That resolves every subpath to the-frontend/src/* instead of dist/*, so
Vite compiles core as app source exactly as it did before. Set it in the site's
vitest and Playwright configs too — miss one and that runner silently tests
dist while the dev server tests src.
Two things about the name. It is not called development: Vite defaults
resolve.conditions to ['module', 'browser', 'development|production'], so a
development key would be enabled on every consumer's dev server — including
external clients, who would resolve into a src/ this package does not
publish, breaking vite dev while vite build passes. And ./testing
deliberately has no apex-src condition: Playwright refuses to strip types
from files under node_modules, so that entry stays prebuilt even in dev.
Alternatively, run npx tsup --watch in core and keep consuming dist/.
Testing a consumer site (the seam suite)
This package ships the test suite consumer sites run against themselves, from
@apextelemed/partner-core/testing. A site supplies a ~40-line adapter
describing itself; the checks come from here.
The division is deliberate. This repo already proves the funnel — npm run
test:e2e drives these pages against a real backend across two tenant
archetypes. What no test here can prove is whether a particular site wired the
package up correctly, and that is a narrow, fast set of questions:
| check | catches |
| --- | --- |
| routes | a route that throws or renders nothing on boot |
| boot | a stored session dropped during a cold page load |
| storage | a key written without the site's prefix, so site and package read different storage |
| singleton | site chrome that never sees the package's auth state |
Everything is mocked — no backend, no credentials, no fixtures. That is a requirement, not a convenience: a suite needing secrets cannot run on a fork PR, cannot be a required check, and so will not be the thing that stops a broken wiring from shipping.
Wiring a site
Three files. See perihelion for a worked example.
e2e/seam.spec.ts — the only site-specific part:
import { test, expect } from '@playwright/test';
import { defineSeamSuite } from '@apextelemed/partner-core/testing';
defineSeamSuite(
{
siteName: 'acme',
storagePrefix: 'acme', // must match the site's configure()
apiBase: '/api',
routes: { home: '/', login: '/login', signup: '/signup', dashboard: '/dashboard' },
nav: { loggedOutText: 'Log In', loggedInText: 'Dashboard', logOutText: 'Log Out' },
patient: { email: '[email protected]', password: 'test1234' },
allowStorageKeys: [], // any localStorage key the SITE writes
},
{ test, expect },
);test and expect are passed in rather than imported inside the suite. Sites
link this package as file:../the-frontend, i.e. a symlink, and Node resolves a
symlinked module's imports from its real path — so an import there would load
a second copy of @playwright/test, which Playwright rejects outright.
playwright.seam.config.ts — call assertSeamEnv() at the top, then a
normal config whose testMatch is seam.spec.ts.
package.json:
"test:seam": "NODE_OPTIONS=--preserve-symlinks playwright test --config playwright.seam.config.ts"--preserve-symlinks is not optional, and cannot be applied from inside the
config — it has to be set before Node starts. Without it the package resolves
outside the site's project root and Playwright fails with "Test not found in the
worker process", which says nothing about symlinks. assertSeamEnv() exists to
turn that into a message that names the real problem. See src/testing/env.ts.
The CI gate
Sites call the reusable workflow in .github/workflows/site-seam-suite.yml
rather than copying it:
jobs:
seam:
uses: thefortifiedgrp/apex-partner-core/.github/workflows/site-seam-suite.yml@dev
secrets:
core-token: ${{ secrets.APEX_CORE_TOKEN }}It handles the sibling checkout of core (../the-frontend), builds the test
kit, and runs the site's test:seam. It no longer clones or builds
apex-survey-helper — core resolves the survey packages from the registry now.
Changing the suite
The checks live in src/testing/. Changing one changes every site at once —
which is the point, and the reason to be careful:
src/testing/storage.tsdeclares every localStorage key this package writes.storage.contract.test.tsscans the source and fails if a key isn't declared, so the downstream audit can't quietly narrow. Add the suffix in the same commit that adds the key.src/testing/fixtures.tsis deliberately free of imports fromsrc/api/— its declarations ship, and a relative type import would resolve against adist/api/that is never built.fixtures.contract.test.tskeeps the shapes honest instead.npm run build:testingcompiles the entry point. Core's CI runs it, because a broken emit here fails in every consumer site's PR rather than in this one.
Three tiers, picked per page
Different pages have different ratios of (logic complexity) × (visual divergence). Pick the highest tier each page can support:
| Tier | Ships | Use when |
|---|---|---|
| 1. Logic | api client, stores, types | always |
| 2. Headless flow | hook returning { state, actions, derived } | site wants full UI control |
| 3. Themed page | <SurveyPage/> etc. that consume usePrimitives() | site is happy to render through the package's structure with its own primitive components |
Sites that adopt Tier 3 must provide a primitive set via context (see below). Sites can mix per page: one site might use the themed <SurveyPage> but write its own dashboard JSX on top of useDashboardData().
Primitive set (Tier 3)
Sites consuming Tier 3 components must provide implementations of the Primitives interface and wrap the app in <PrimitivesProvider>:
import { PrimitivesProvider, type Primitives } from '@apextelemed/partner-core';
import { Button, Card, TextField, /* ... */ } from './primitives';
const sitePrimitives: Primitives = {
Card,
TextField,
Textarea,
Select,
RadioGroup,
CheckboxGroup,
Button,
Banner,
StatusPill,
Spinner,
Heading,
StepIndicator,
};
render(() => (
<PrimitivesProvider value={sitePrimitives}>
<App />
</PrimitivesProvider>
), root);The interface is defined in src/primitives.tsx. It is intentionally small — every prop on a primitive is a contract every consumer site must implement. Resist the urge to add prop variants; extend the interface only when a Tier 3 component genuinely needs the variant.
What's shipped
| Surface | Subpath | Tier |
|---|---|---|
| Boot configuration | /config | 1 |
| API client + types | /api | 1 |
| Stores (auth, cart, medication) | /stores | 1 |
| Headless flow hooks | /flows | 2 |
| Tier 3 primitives contract | /primitives | 3 |
| Tier 3 page components | /pages | 3 |
| Themes (CSS variables + UnoCSS preset) | /theme, /theme/{admin,flow,affiliate,member-portal}.css | — |
| Consumer seam suite | /testing | — |
Tier 3 pages
Nineteen components ship from /pages. Per-page props and behavior are
documented on the docs site;
this list is the inventory.
Customer funnel — <SurveyV2Page> (questionnaire composed from drugIds,
qualifies guests), <SurveyPage> (deprecated v1 — needs a medication chosen
first), <CheckoutPage>, <CartCheckoutPage>, <SignupPage>,
<RegisterPage>, <SubscribePage>, <UpdatePaymentMethod>.
Auth — <ForgotPasswordPage>, <ResetPasswordPage>,
<AffiliateForgotPassword>, <AffiliateResetPassword>.
Portals — <AdminDashboard>, <EmailTemplates>,
<PatientDashboardPreview>, <MemberPortal>, <AffiliatePortal>,
<AffiliateRegister>, <AffiliateLogin>.
Tier 2 flow hooks
Twenty hooks ship from /flows, alongside the pure helpers each flow module
exports (formatters, buildTrackerView, the customer-360 derivations, the
address and subscription-status vocabularies). Signatures are on the
docs site.
- Survey —
useSurveyV2Flow;useSurveyFlow(deprecated v1) - Purchase —
useCheckoutFlow,useCartCheckoutFlow,useSignupFlow,useRegisterFlow,useSubscribeFlow,useSubscriptionIntakeFlow,useUpdatePaymentMethodFlow - Portal data —
useDashboardData,useMemberPortalData,useMessagesThread - Catalog —
useCatalog,useProductDetail,useMedicationSelect - Affiliate —
useAffiliateStats,useAffiliateConversions,useAffiliateDownline,useAffiliateLinks - Forms —
useAddressPair
useSubscriptionIntakeFlow quotes the member's own offer as well as a
typed promo code: with a session it reads offersApi.mine() and pricing()
applies whichever of the two is worth more (never both; a tie goes to the
code), the same rule the server charges by. pricing().offer is set only when
the offer is the one applied — render it as its own line
(offerSummaryText(offer) gives "$10 off every month", offer.deadlineText
the "claim by" date) — and promoNotice() explains a valid code that lost to
it. A site that totals a cart itself uses the same rule through
bestOfferDiscount(offers, { productIds, baseCents }).
Hooks with no shared JSX (dashboard, messages, catalog, affiliate) are Tier 2 only: the site renders its own page on top.
Deep-linking into Messages
<MemberPortal>'s Messages tab reads its destination from the URL, so any
link into the portal — an email, a support macro, another tab's CTA — can
pre-select it instead of landing on the merged conversation list:
#messages— open the tab itself.?c=<id>— open this exact conversation/thread. Wins overto/catwhen both are present; its channel is implied by the thread's own source.?to=care|support— pre-select which side of the merged inbox to land on.?cat=<category>— a support category (billing,orders,account,technical,other); support-only, and impliesto=supportwhentois omitted. Unknown values on any of these params are ignored, not surfaced.
The same shape is MessagesIntent (exported from /flows), which every
in-portal CTA passes to onOpenMessages(intent?) — the URL query params are
just that intent's serialization for links, not a separate mechanism.
Admin
<AdminDashboard> is themed via CSS variables, not the Primitives interface — back-office UIs don't differentiate the brand. Sites import the default stylesheet once and override individual variables to match their look:
// src/index.tsx
import '@apextelemed/partner-core/theme/admin.css';/* site stylesheet — override any variables that need to match the brand */
:root {
--app-admin-bg: #f7f9fb;
--app-admin-fg: #0f172a;
--app-admin-radius: 0;
--app-admin-font-display: 'Orbitron', system-ui, sans-serif;
}For sites that use UnoCSS, an optional preset adds shortcut classes (bg-admin-bg, text-admin-fg, etc.):
import { adminPreset } from '@apextelemed/partner-core/theme';
export default defineConfig({
theme: { ...adminPreset.theme },
shortcuts: { ...adminPreset.shortcuts },
});The full variable list is exported as ADMIN_CSS_VARIABLES from /theme for sites that want to generate override stylesheets programmatically.
<AdminDashboard> has no single Promotions tab: Sales, Promo Codes and Offers
are separate tabs, grouped under Marketing with Affiliates and Funnel. They are
tabs of the dashboard, not separate pages.
The three are different things. A sale is for everyone and a promo code for
whoever types it; an offer (offers tab, adminApi.getOffers() and
friends) is a discount one person holds before they subscribe, with their own
deadline to claim it. The tab lists offers with their grant counts and the
sequences handing each out, and each offer has a grants page for giving it to
a person by hand or taking an unclaimed grant back. A sequence hands an offer
out from its Settings (Offer + valid-for days), next to the savings-members-only
switch.
What a sequence achieved is the Results tab (sequence-results, under Email
after Sequences): per sequence, how many people were emailed, followed a link
and bought; the same per email; the offer's grants by state; what buyers have
paid; and who they were. A site that passes enabledTabs has to add
'sequence-results' to see it. Links are counted by configure() itself: a
sequence email's link arrives carrying ?fs=…, and the first configure() of
the page load posts it to the backend and takes it off the address bar. Sites
do nothing for this beyond being on a version that has it.
Customers and the detail-view standard
The Customers tab is a customer 360: the list carries a lifecycle bucket per
account (active, past due, paused, payment unverified, canceled, card
declined, awaiting prescriber, never subscribed) with facet counts, and
opening a row replaces the list with a full-width detail — lifecycle reason,
needs-attention list, journey stepper, subscriptions with billing attempts,
prescriptions on the member-portal fulfillment beads, payments, support
threads, pre-signup activity and a timeline. Subscriptions live inside it; the
'subscriptions' tab id still compiles but renders the customer list filtered
to active subscribers.
Deep links: the tab reads /admin/customers/<uid> at mount and keeps it in
sync with history.pushState when it is mounted under that path. Sites that
want to own the URL pass customers={{ activeUid, onSelectCustomer }}, the
same shape as affiliates. Sites whose admin router maps paths by exact match
should prefix-match /admin/customers so a copied link lands on the customer.
The layout primitives the customer detail is built from (DetailHeader,
DetailGrid, Section, AlertItem, Stepper, Timeline, Chip, Modal
in pages/admin/shared.tsx, styled by the app-admin-detail*,
app-admin-section*, app-admin-alert*, app-admin-stepper* and
app-admin-timeline* classes) are the standard for admin detail views; the
remaining side panels move onto them tab by tab. No new CSS variables were
added — the detail view themes through the existing contract.
What stays out of this package
- Page-level JSX for customer-facing pages where visual divergence is too high (Dashboard, Catalog, Login, Home, ProductDetail, Messages)
- Branding components (OrbitField, MoleculeViz, hero illustrations)
- Route configuration (
App.tsx) - Per-site primitive implementations (each site builds its own Button/Card/TextField/etc. matching its design language)
- Theme tokens (
uno.config.ts) - Per-site product catalogs / seed data
