Portal Extensions
A portal extension is a Vue + TypeScript module bundled in a Hantera app. When the app is installed, Hantera loads the extension and calls the default export in portal/index.ts with a Portal instance.
From there, the extension can:
- Register UI components into existing portal slots
- Register custom views and navigation entries
- Register services that the portal can call (service contracts)
export default function (portal: Portal) {
portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent);
}Registering a component
The portal exposes predefined “slots” which act as extension points in existing UI surfaces. Slots are grouped under a few namespaces in apps, such as:
- apps.orderViewSlots
- apps.standardServicesSlots
- apps.workspaceSlots
- apps.legacySlots
- apps.defineService
When you register a component to a slot, the portal renders your component in that location.
portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent);This tells the portal:
When rendering the delivery footer in the order view, mount MyComponent here.
Accessing slot context inside the component
Each slot gives your component a typed context within the UI surface.Inside the component, the context has to request for the same slot:
const ctx = apps.componentContext(apps.orderViewSlots.delivery.footer);
const delivery = ctx.delivery;Here’s what is happening:
apps.orderViewSlots.delivery.footeridentifies the UI surface.componentContext(...)returns the typed runtime context for that surface.- Because this slot belongs to the order delivery view, the context includes delivery-related data.
for example:
<template>
Current delivery number: {{ delivery?.deliveryNumber }}
</template>The component does not fetch this data manually. The portal injects the delivery number through the slot context.
Registering views
A view is a registered UI route in the portal. Views are implemented as Vue components and registered from portal/index.ts.
const testView = portal.registerView("test", Test);registerView() returns metadata about the view, including its fullPath, which can be used to open the view from navigation.
Registering NavHub entries
NavHub entries add navigation items in the portal. Typically, you register a section and an item that opens your view.
portal.registerNavHubSection({
id: "test",
items: [
{
label: "My test view",
action(viewContext) {
viewContext.openView(testView.fullPath, {});
},
},
],
});View context and state
Each view has a context object that includes route information and a state object. The state can be used to store view-specific values while the view is active.
const context = useContext();
if (!context.currentView.value!.state.opened) {
context.currentView.value!.state.opened = Date.now();
}Registering a service
A service is how an app exposes functionality to the portal through a contract. Instead of importing and syncing external data into Hantera, the portal can call your service at runtime and use the returned results inside standard UI flows.
Services are registered from portal/index.ts:
portal.registerService(apps.standardServices.productsService, (serviceContext) => ({
lookup(productNumber: string) {
// ...
},
order(order, phrase: string) {
// ...
},
search(phrase: string) {
// ...
},
}));Where service code runs
Services are registered in portal/index.ts as part of the portal extension. They run inside the portal runtime, but they are not executed inside a specific Vue component’s setup context.
This means:
A service implementation cannot use component-only composition hooks such as useContext() or useAppContext().
Instead, the portal provides a serviceContext object when the service is registered.
Use serviceContext for portal-facing utilities such as:
- serviceContext.processes: Used to run tasks with visible progress overlays, report errors, and surface long-running work in the portal UI.
- Any other portal-provided helpers exposed through the service context.
Example:
portal.registerService(apps.standardServices.productsService, (serviceContext) => ({
async lookup(productNumber: string) {
serviceContext.processes.start({
code: "LOOKUP_STARTED",
message: "Looking up product..."
})
// fetch logic here
return []
}
}))Consuming services inside Vue components
Although services are defined in index.ts, they can be consumed from Vue components.
Example inside a .vue file:
const appContext = useAppContext()
const productServices = appContext.getServices(
apps.standardServices.productsService
)
const product =
productServices.length > 0
? await productServices[0].lookup("palissade-table-iron-red-small")
: nullHere:
- The Vue component uses useAppContext() to retrieve registered services.
- The component calls lookup().
- The service runs in the portal runtime and returns data back to the component.
Multiple providers
Multiple apps can register the same service contract. The portal can merge results from multiple providers into a unified suggestion list.
The products service contract
The standard products service contract is designed to provide product suggestions in different contexts. It typically exposes three methods:
Lookup
Use lookup when the portal already has a specific product number and wants details to display or validate.
Input: a known productNumber
Output: a single product suggestion (or null if not found)
Example:
lookup(productNumber: string) {
return Promise.resolve({
productNumber,
description: "Test lookup",
});
}Order
Use order when the user is editing an order and needs product suggestions while searching.
This is the order editor path, so it is where you usually want richer behavior:
- tailor results based on order context
- tailor pricing, tax behavior, availability, or localization
- return SKUs in the structure the portal expects for order lines
Input: the current order context + a search phrase
Output: a list of OrderProduct suggestions
Example:
async order(order, phrase) {
const response = await fetch(`<external api link>`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query GetProducts {
browse {
product(term: "${phrase}") {
hits {
variants {
sku
firstImage { url }
name
defaultPrice
}
}
}
}
}`,
}),
});
if (!response.ok) {
serviceContext.processes.error({
code: "ERROR",
message: "Product lookup failed",
});
return [];
}
const hits = (await response.json()).data.browse.product.hits as {
variants: { sku: string; firstImage: { url: string }; name: string; defaultPrice: number }[];
}[];
return hits
.flatMap((h) => h.variants)
.map((v) => {
const skus: Record<string, Decimal> = {};
skus[v.sku] = new Decimal(1);
return <apps.standardServices.OrderProduct>{
description: v.name,
productNumber: v.sku,
imageUrl: v.firstImage.url,
skus,
unitPrice: new Decimal(v.defaultPrice),
taxFactor: new Decimal(0),
};
});
}Search
Use search for product suggestions outside an order context. For:
- claims/returns flows
- a product picker that is not tied to an order editor
Input: a search phrase
Output: a broader list of suggestions (contract-specific type)
Example:
search(phrase: string) {
return Promise.resolve([]);
}Placing external endpoints and secrets
If your service needs an external endpoint or key (URL, token, etc.), don’t hardcode it. Prefer app settings in h_app.yaml so it can be configured per tenant, then read it at runtime (via registry-backed settings). This keeps the portal extension portable and avoids baking environment values into code. Refer to App Settings