@proveanything/portal-framework
v0.1.3
Published
Configurable portal frame and layout components for SmartLinks micro-apps
Downloads
513
Maintainers
Readme
@proveanything/portal-framework
A configurable portal frame and content orchestration system for building engagement platforms on top of the SmartLinks ecosystem.
Overview
Portal Framework provides the building blocks for creating mobile-first portal experiences that navigate SmartLinks entities (Collections → Products → Proofs). It handles:
- Layout Configuration — Headers, side menus, tabs (top/bottom), and special features
- Content Orchestration — Manages navigation through the entity hierarchy
- Micro-app Integration — Embeds external apps via iframes with context passing
- Two-Layer Configuration — Platform-level constraints + collection-level overrides
- Theming — Full CSS variable-based theming with dark mode support
Installation
npm install @proveanything/portal-frameworkQuick Start
import {
PortalFrame,
ContentOrchestrator,
NavigationProvider,
usePortalConfig,
resolveConfig,
} from '@proveanything/portal-framework';
const App = () => {
const { config } = usePortalConfig({
fetchConfig: async () => {
// Fetch collection-level overrides from SmartLinks
return await SL.appConfiguration.getConfig({ collectionId, appId });
},
});
// Merge template constraints with collection overrides
const finalConfig = resolveConfig(myPlatformTemplate, config);
return (
<NavigationProvider collectionId={collectionId}>
<PortalFrame config={finalConfig}>
<ContentOrchestrator
collectionId={collectionId}
availableApps={myApps}
/>
</PortalFrame>
</NavigationProvider>
);
};Architecture: Two-Layer Configuration
Portal apps use a two-layer configuration system that separates platform constraints (what's possible) from collection customization (what a brand chooses).
Layer 1: Platform Template (Static)
Defined in your app's codebase as a PlatformTemplate. This is hardcoded — editing it requires a code change. It controls:
- Which apps are available for menus/tabs
- Which features are enabled (QR scanner, language selector, account)
- Locked items that collections cannot remove
- Maximum item counts and customization limits
// src/config/template.ts — lives in your app, not in the API
import type { PlatformTemplate } from '@proveanything/portal-framework';
export const myTemplate: PlatformTemplate = {
id: 'fan-connect',
name: 'Fan Connect',
availableApps: [
{ id: 'home', name: 'Home', icon: 'Home', category: 'Core' },
{ id: 'fixtures', name: 'Fixtures', icon: 'Calendar', category: 'Sports' },
{ id: 'points', name: 'Points', icon: 'Award', category: 'Engagement' },
// ... only apps THIS platform supports
],
availableFeatures: {
qrScanner: true,
languageSelector: true,
account: true,
pointsCollection: true,
},
// Locked items cannot be removed by collection admins
requiredBottomTabs: [
{ id: 'points-tab', label: 'Points', icon: 'Award', appId: 'points',
visible: true, order: 4, locked: true },
],
constraints: {
allowCustomLinks: true,
allowDividers: true,
maxBottomTabs: 5,
maxSideMenuItems: 15,
allowHeaderCustomization: true,
allowSideMenuPositionChange: true,
},
// Starting config for new collections
defaultConfig: {
bottomTabs: {
enabled: true,
showLabels: true,
items: [
{ id: 'home-tab', label: 'Home', icon: 'Home', appId: 'home', visible: true, order: 0 },
{ id: 'fixtures-tab', label: 'Fixtures', icon: 'Calendar', appId: 'fixtures', visible: true, order: 1 },
],
},
},
};Layer 2: Collection Config (Dynamic)
Stored via SL.appConfiguration.setConfig() and loaded at runtime. Each collection (brand) can customize their portal within the bounds set by the template:
// Loaded from SmartLinks API at runtime
const collectionOverrides = await SL.appConfiguration.getConfig({
collectionId,
appId: 'portal-config',
});
// e.g.:
// {
// header: { title: 'Arsenal FC', logoUrl: 'https://...' },
// bottomTabs: {
// items: [
// { id: 'home-tab', ... },
// { id: 'shop-tab', label: 'Shop', icon: 'ShoppingCart', appId: 'shop', ... },
// ]
// }
// }Merging with resolveConfig()
The resolveConfig utility merges both layers while enforcing all constraints:
import { resolveConfig } from '@proveanything/portal-framework';
const finalConfig = resolveConfig(myTemplate, collectionOverrides);What resolveConfig enforces:
| Constraint | Behavior |
|-----------|----------|
| locked: true items | Always present, cannot be removed |
| maxBottomTabs / maxSideMenuItems / maxTopTabs | Excess unlocked items are trimmed |
| allowHeaderCustomization: false | Collection header overrides are ignored |
| allowSideMenuPositionChange: false | Side menu position stays at template default |
| allowCustomLinks: false | Custom-link menu items are filtered out |
| allowDividers: false | Divider menu items are filtered out |
| availableFeatures | Disabled features are forced off regardless of overrides |
| availableApps | Menu/tab items referencing unavailable apps are filtered out |
Microsite Mode (Default Portal)
For SMBs that just want a custom domain with their own portal, use a permissive template and resolve collectionId from the domain:
// Default Portal app — most permissive template
const micrositeTemplate: PlatformTemplate = {
id: 'microsite',
name: 'Custom Portal',
availableApps: [ /* all apps */ ],
availableFeatures: { qrScanner: true, languageSelector: true, account: true },
constraints: {
allowCustomLinks: true,
allowDividers: true,
maxSideMenuItems: 20,
maxBottomTabs: 5,
allowHeaderCustomization: true,
allowSideMenuPositionChange: true,
},
};
// Resolve collectionId from domain (future SDK support)
const collectionId = await resolveCollectionFromDomain(window.location.hostname);
const overrides = await SL.appConfiguration.getConfig({ collectionId, appId: 'portal-config' });
const config = resolveConfig(micrositeTemplate, overrides);Components
Layout Components
| Component | Purpose |
|-----------|---------|
| <PortalFrame> | Main container with header, menus, and tabs |
| <PortalHeader> | Header with logo, back button, and action buttons |
| <PortalSideMenu> | Slide-out menu (left or right) |
| <PortalTabs> | Tab navigation (top or bottom) |
| <DynamicIcon> | Renders Lucide icons by name |
Orchestration Components
| Component | Purpose |
|-----------|---------|
| <ContentOrchestrator> | Manages which content to show based on navigation state |
| <NavigationProvider> | Context provider for navigation state |
| <AppIframeRenderer> | Renders micro-apps in iframes with caching |
| <OrchestratedPortal> | All-in-one: PortalFrame + ContentOrchestrator + NavigationProvider |
Default Views
| Component | Purpose |
|-----------|---------|
| <DefaultProductList> | Grid of products in a collection |
| <DefaultProofView> | Single proof display with attestations |
| <DefaultAppTabs> | Tab interface for multiple apps on a proof |
| <OrchestratorBreadcrumbs> | Navigation breadcrumbs |
| <AnimatedContent> | Fade transitions between views |
Customization Slots
The ContentOrchestrator provides slot-based customization at each hierarchy level. Higher levels support more customization; lower levels are increasingly standardized.
Available Slots
| Slot | Level | Props Interface | Use Case |
|------|-------|-----------------|----------|
| collectionHome | Collection | CollectionSlotProps | Full override of the collection landing page |
| collectionHeader | Collection | CollectionSlotProps | Custom header above the product list |
| productList | Collection | ProductListSlotProps | Custom product grid/list layout |
| productHome | Product | ProductSlotProps | Full override of the product detail page |
| productHeader | Product | ProductSlotProps | Custom header above product app tabs |
| proofHeader | Proof | ProofSlotProps | Custom header above proof details |
Slot Props
// Collection-level props
interface CollectionSlotProps {
collection: Collection;
apps: CollectionApp[];
onNavigateToProduct: (productId: string) => void;
onNavigateToApp: (appId: string) => void;
}
// Product list props
interface ProductListSlotProps {
collection: Collection;
apps: CollectionApp[];
onNavigateToProduct: (productId: string) => void;
onNavigateBack: () => void;
}
// Product-level props
interface ProductSlotProps {
collection: Collection;
product: Product;
apps: CollectionApp[];
onNavigateToProof: (proofId: string) => void;
onNavigateToApp: (appId: string) => void;
onNavigateBack: () => void;
}
// Proof-level props
interface ProofSlotProps {
collection: Collection;
product: Product;
proof: Proof;
apps: CollectionApp[];
attestations: Attestation[];
onNavigateToApp: (appId: string) => void;
onNavigateBack: () => void;
}Customization Gradient
Collection level ████████████████ High customization
Product level ██████████ Medium customization
Proof level ████ Low customization (standardized)Example: Custom Collection Landing Page
<ContentOrchestrator
collectionId={collectionId}
slots={{
collectionHome: ({ collection, apps, onNavigateToProduct }) => (
<div className="p-4">
<h1 className="text-2xl font-bold">{collection.name}</h1>
<p>{collection.description}</p>
{/* Your custom product grid */}
<CustomProductGrid onSelect={onNavigateToProduct} />
</div>
),
}}
defaults={{
appLayout: 'tabs', // 'tabs' | 'accordion' | 'stack'
proofApps: ['warranty', 'info'], // Filter which apps show at proof level
}}
/>Embedding Micro-Apps
The AppIframeRenderer component embeds SmartLinks micro-apps inside iframes with full lifecycle management.
Basic Usage
import { AppIframeRenderer } from '@proveanything/portal-framework';
<AppIframeRenderer
appId="warranty-registration"
collectionId="abc123"
productId="prod456"
proofId="proof789"
onReady={() => console.log('App loaded')}
onRouteChange={(path, state) => console.log('Route:', path)}
/>Props
| Prop | Type | Description |
|------|------|-------------|
| appId | string | The micro-app identifier |
| collectionId | string | Current collection context |
| productId? | string | Current product context |
| proofId? | string | Current proof context |
| isAdmin? | boolean | Whether to load admin interface |
| cache? | IframeCacheData | Pre-loaded data to avoid loading delays |
| version? | 'stable' \| 'development' | Which app version to load |
| appUrl? | string | Override URL for local development |
| initialPath? | string | Deep link path within the app |
| onRouteChange? | (path, state) => void | Called when iframe route changes |
| onResize? | (height) => void | Called when iframe height changes |
| onReady? | () => void | Called when app is loaded and ready |
| onError? | (error) => void | Called on load failure |
| minHeight? | number | Minimum iframe height (default: 200) |
Context Passing
The renderer passes context to embedded apps via URL parameters:
https://app.example.com/#/?collectionId=abc&productId=prod456&proofId=proof789&appId=warrantyApps receive these parameters and use them to load relevant data via the SmartLinks SDK.
Pre-loading with Cache
Pass pre-fetched data to avoid loading flickers:
<AppIframeRenderer
appId="warranty"
collectionId={collectionId}
cache={{
collection: collectionData,
product: productData,
proof: proofData,
user: { uid: 'user123', email: '[email protected]' },
}}
/>PostMessage Communication
The AppIframeRenderer uses the SmartLinks IframeResponder which handles:
| Event | Direction | Description |
|-------|-----------|-------------|
| smartlinks-route-change | App → Parent | App navigated to a new route |
| smartlinks-resize | App → Parent | App content height changed |
| smartlinks-ready | App → Parent | App finished loading |
| smartlinks-auth-login | App → Parent | User logged in within the app |
| smartlinks-auth-logout | App → Parent | User logged out within the app |
| Cache updates | Parent → App | Updated entity data pushed to app |
CollectionApp Metadata
Apps discovered via SL.collection.getAppsConfig(collectionId) include rich metadata:
interface CollectionApp {
id: string;
srcAppId?: string; // Original app template ID
name: string;
publicIframeUrl?: string; // URL for embedding
active?: boolean;
icon?: string; // Lucide icon name
faIcon?: string; // Font Awesome icon identifier
description?: string;
category?: 'Authenticity' | 'Documentation' | 'Commerce' | 'Engagement'
| 'AI' | 'Digital Product Passports' | 'Integration' | 'Web3' | 'Other';
// Visibility & access control
ownersOnly?: boolean; // Only show to proof owners
hidden?: boolean; // Hidden from UI (background apps)
// Widget & embedding support
manifestUrl?: string; // URL to app's manifest for widget definitions
supportsDeepLinks?: boolean; // App supports multi-page navigation
// Usage contexts — which levels this app appears at
usage?: {
collection?: boolean;
product?: boolean;
proof?: boolean;
widget?: boolean;
};
}Filtering by Visibility
// Only show apps the current user should see
const visibleApps = apps.filter(app => {
if (app.hidden) return false;
if (app.ownersOnly && !isOwner) return false;
return true;
});Configuration Types
PortalLayoutConfig
interface PortalLayoutConfig {
header: HeaderConfig;
sideMenu: SideMenuConfig;
topTabs: TopTabsConfig;
bottomTabs: BottomTabsConfig;
specialFeatures: SpecialFeaturesConfig;
}See the type definitions for full interface details.
PlatformTemplate
interface PlatformTemplate {
id: string;
name: string;
description?: string;
logoUrl?: string;
primaryColor?: string;
availableApps: AppDefinition[];
availableFeatures: {
qrScanner?: boolean;
languageSelector?: boolean;
account?: boolean;
quickActions?: boolean;
pointsCollection?: boolean;
[key: string]: boolean | undefined;
};
requiredSideMenuItems?: SideMenuItem[];
requiredBottomTabs?: TabItem[];
requiredTopTabs?: TabItem[];
constraints: {
allowCustomLinks: boolean;
allowDividers: boolean;
maxSideMenuItems?: number;
maxBottomTabs?: number;
maxTopTabs?: number;
allowHeaderCustomization: boolean;
allowSideMenuPositionChange: boolean;
};
defaultConfig?: Partial<PortalLayoutConfig>;
}Hooks
usePortalConfig
Manages portal configuration state with helpers for updates:
const {
config,
isLoading,
error,
updateHeader,
updateSideMenu,
addSideMenuItem,
updateSideMenuItem,
removeSideMenuItem,
reorderSideMenuItems,
updateTopTabs,
updateBottomTabs,
addTabItem,
removeTabItem,
updateSpecialFeatures,
resetConfig,
} = usePortalConfig({
initialConfig, // Optional starting config
fetchConfig, // Async config fetcher
onChange, // Change callback
});useNavigation
Access and control navigation state:
const {
state, // NavigationState
actions, // NavigationActions
getBreadcrumbs, // () => Breadcrumb[]
} = useNavigation();
// State includes: currentLevel, selectedProduct, selectedProof, availableApps, etc.
// Actions include: navigateToProduct, navigateToProof, navigateToApp, navigateBackuseNavigationState
Subscribe to navigation state with fetch integration:
const { state, products, proofs, isLoading } = useNavigationState({
collectionId,
fetchProducts: async (collectionId) => { ... },
fetchProofs: async (collectionId, productId) => { ... },
});Navigation Levels
| Level | What's Displayed | Default Component |
|-------|------------------|-------------------|
| collection | List of products | <DefaultProductList> |
| product | Product detail + app tabs | <DefaultAppTabs> |
| proof | Proof details + app tabs | <DefaultProofView> + <DefaultAppTabs> |
Styling
The framework uses Tailwind CSS with CSS custom properties. Override variables in your app:
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}Example Configurations
The package includes example configurations for reference:
import {
fanConnectSportsTemplate, // Sports club engagement
fanConnectMusicTemplate, // Band/artist engagement
smartDocentTemplate, // Museum/gallery experience
genericTemplate, // Starter configuration
} from '@proveanything/portal-framework';
// Use as reference, then build your own:
const finalConfig = resolveConfig(fanConnectSportsTemplate, collectionOverrides);These are examples, not presets. Use them as reference when building your own configurations.
Building a Portal App
For production use, create a separate application that imports this framework:
// my-portal-app/src/App.tsx
import {
OrchestratedPortal,
NavigationProvider,
resolveConfig,
} from '@proveanything/portal-framework';
import { myTemplate } from './config/template';
export const App = () => {
const collectionId = getCollectionIdFromContext();
const overrides = useCollectionConfig(collectionId); // fetch from API
const config = resolveConfig(myTemplate, overrides);
return (
<NavigationProvider collectionId={collectionId}>
<OrchestratedPortal
collectionId={collectionId}
config={config}
showBreadcrumbs={true}
enableAnimations={true}
/>
</NavigationProvider>
);
};Site Injection System
The framework supports a Site Injection system that lets parent platforms inject custom React components without modifying the framework:
- Actions: Components triggered from tabs, menus, or programmatically — presented as sheets, modals, or full pages
- Zones: Passive components rendered at
contentHeader,contentFooter,headerRight, andheroOverlay
const injections: SiteInjections = {
actions: {
'ai-chat': {
id: 'ai-chat',
component: AiChatPanel,
presentation: 'sheet',
showAt: ['product', 'proof'],
},
},
zones: {
contentHeader: StatusBanner,
},
};
<OrchestratedPortal
collectionId={collectionId}
config={config}
siteInjections={injections}
/>See docs/site-injection.md for full documentation.
Documentation
Detailed guides are available in the docs/ directory:
| Guide | Description | |-------|-------------| | Architecture | System layers, component roles, configuration flow | | Site Injection | Actions, zones, badges, level-aware visibility | | Direct Component Rendering | Same-origin widget loading, bare import shimming | | Cross-App Communication | postMessage protocols, shared contexts, capability contracts | | Authentication | AuthKit integration, auth modal, custom auth | | Getting Started | Lovable initialization prompt for new portal apps |
License
MIT
