Core Features

    Widget System

    Embeddable React components for SmartLinks apps with cross-app navigation and theming.

    SmartLinks Widget System

    This document covers the widget system that allows SmartLinks apps to expose embeddable React components for use in parent applications (like a SmartLinks portal or homepage).


    Overview

    Widgets are self-contained React components that:

    • Run inside the parent React application (not iframes)
    • Receive standardized context props
    • Inherit styling from the parent via CSS variables
    • Can trigger structured cross-app navigation within the parent portal
    ┌─────────────────────────────────────────────────────────────────┐
    │ Parent SmartLinks Portal (React 18)                             │
    │                                                                 │
    │  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
    │  │ Competition  │  │ Music App    │  │ Warranty     │          │
    │  │ Widget       │  │ (ESM import) │  │ Widget       │          │
    │  │ (ESM import) │  │              │  │ (ESM import) │          │
    │  └──────────────┘  └──────────────┘  └──────────────┘          │
    │         ↑                 ↑                 ↑                   │
    │         │                 │                 │                   │
    │   Props + SL SDK    Props + SL SDK    Props + SL SDK           │
    │                                                                 │
    └─────────────────────────────────────────────────────────────────┘
    

    Dual-Mode Rendering

    Widgets can run in two modes, and the same widget code must work in both:

    ModeHow It WorksWhen Used
    Direct ComponentWidget runs directly in the parent's React contextDefault - when loaded as ESM/UMD in the parent app
    IframeWidget runs inside an iframe with its own URLFallback or when full isolation is needed

    The Critical Rule: No Router Wrappers

    ❌ Your widget component must NOT be wrapped in a <Router>, <MemoryRouter>, <HashRouter>, or <BrowserRouter>.

    Why? In direct component mode, the parent application already provides routing context. If your widget includes its own Router, React Router will throw: "You cannot render a <Router> inside another <Router>".

    Where routing goes:

    • ✅ Your iframe entry point (App.tsx / main.tsx) should use HashRouter
    • ❌ Your exported widget (PublicComponent.tsx) should NOT include any Router

    Widgets are typically single-view components and don't need internal routing. If you need multi-page navigation, use a container instead.

    The useAppContext() Pattern

    To write widgets that work identically in both modes, use this abstraction pattern:

    // src/hooks/useAppContext.ts
    import { useContext, createContext, useMemo } from 'react';
    import { useSearchParams } from 'react-router-dom';
    
    export interface AppContextValue {
      collectionId: string;
      appId: string;
      productId?: string;
      proofId?: string;
      pageId?: string;
      lang?: string;
      user?: { id: string; email: string; name?: string };
      SL: typeof import('@proveanything/smartlinks');
      onNavigate?: (request: any) => void;
    }
    
    export const AppContext = createContext<AppContextValue | null>(null);
    
    /**
     * Returns app context regardless of rendering mode.
     * - Direct component mode: reads from AppContext (props)
     * - Iframe mode: reads from URL search params
     */
    export function useAppContext(): AppContextValue {
      const ctx = useContext(AppContext);
      
      // If context exists, we're in direct-component mode
      if (ctx) return ctx;
      
      // Otherwise, we're in iframe mode — read from URL params
      const [searchParams] = useSearchParams();
      const SL = (window as any).SL ?? require('@proveanything/smartlinks');
    
      return useMemo(() => ({
        collectionId: searchParams.get('collectionId') ?? '',
        appId: searchParams.get('appId') ?? '',
        productId: searchParams.get('productId') ?? undefined,
        proofId: searchParams.get('proofId') ?? undefined,
        pageId: searchParams.get('pageId') ?? undefined,
        lang: searchParams.get('lang') ?? undefined,
        SL,
      }), [searchParams, SL]);
    }
    

    Usage in your widget:

    // src/exports/PublicComponent.tsx
    import { AppContext } from '@/hooks/useAppContext';
    import { WidgetContent } from '@/components/WidgetContent';
    
    export const PublicComponent = (props: Record<string, any>) => {
      return (
        <AppContext.Provider value={props}>
          <WidgetContent />
        </AppContext.Provider>
      );
    };
    
    // src/components/WidgetContent.tsx
    import { useAppContext } from '@/hooks/useAppContext';
    
    export function WidgetContent() {
      const { collectionId, productId, SL } = useAppContext();
      // Works in both direct-component and iframe modes!
      return <div>Collection: {collectionId}</div>;
    }
    

    Widget Props Interface

    All widgets receive the SmartLinksWidgetProps interface:

    interface SmartLinksWidgetProps {
      // Required context
      collectionId: string;
      appId: string;
      
      // Optional context
      productId?: string;
      proofId?: string;
      
      // User info (if logged in)
      user?: {
        id?: string;
        email?: string;
        name?: string;
        admin?: boolean;
      };
      
      // SmartLinks SDK (pre-initialized by parent)
      SL: typeof import('@proveanything/smartlinks');
      
      // Callback to navigate within the parent application
      onNavigate?: (request: NavigationRequest | string) => void;
      
      // Base URL to the full public portal for deep linking
      publicPortalUrl?: string;
      
      // Size hint for responsive rendering
      size?: 'compact' | 'standard' | 'large';
      
      // Internationalization
      lang?: string;
      translations?: Record<string, string>;
    }
    

    Props Explained

    PropTypeDescription
    collectionIdstringThe collection context (required)
    appIdstringThis app's identifier (required)
    productIdstring?Optional product context
    proofIdstring?Optional proof context
    userobject?Current user info if authenticated
    SLtypeof SLPre-initialized SmartLinks SDK
    onNavigatefunction?Callback to navigate within parent app (accepts NavigationRequest or legacy string)
    publicPortalUrlstring?Base URL to full portal for deep links
    sizestring?Size hint: 'compact', 'standard', or 'large'
    langstring?Language code (e.g., 'en', 'de', 'fr')
    translationsobject?Translation overrides

    Cross-App Navigation

    Widgets can navigate to other apps within the portal using structured navigation requests. This allows a widget to say "open app X with these parameters" without knowing the portal's URL structure or hierarchy state. The portal orchestrator receives the request, preserves the current context (collection, product, proof, theme, auth), and performs the actual navigation.

    NavigationRequest

    interface NavigationRequest {
      /** Target app ID to activate */
      appId: string;
      /** Deep link / page within the target app (forwarded as pageId) */
      deepLink?: string;
      /** Extra params forwarded to the target app (e.g. { campaignId: '123' }) */
      params?: Record<string, string>;
      /** Optionally switch to a specific product before showing the app */
      productId?: string;
      /** Optionally switch to a specific proof before showing the app */
      proofId?: string;
    }
    

    Usage Examples

    const MyWidget: React.FC<SmartLinksWidgetProps> = ({ onNavigate, ...props }) => {
    
      // Navigate to another app, keeping current context
      const handleEnterCompetition = () => {
        onNavigate?.({
          appId: 'competition-app',
          deepLink: 'enter',
          params: { campaignId: '123', ref: 'widget' },
        });
      };
    
      // Navigate to a specific product's app
      const handleViewProduct = (productId: string) => {
        onNavigate?.({
          appId: 'product-info',
          productId,
        });
      };
    
      // Navigate to a proof-level app
      const handleViewWarranty = (proofId: string) => {
        onNavigate?.({
          appId: 'warranty-app',
          proofId,
          productId: 'prod-123', // required when jumping to proof level
        });
      };
    
      return (
        <Card>
          <Button onClick={handleEnterCompetition}>Enter Competition</Button>
          <Button onClick={() => handleViewProduct('prod-456')}>View Product</Button>
        </Card>
      );
    };
    

    How It Works End-to-End

    Widget clicks "Enter Competition"
      → onNavigate({ appId: 'competition', deepLink: 'enter', params: { ref: 'widget' } })
      → Portal orchestrator receives NavigationRequest
      → Calls actions.navigateToApp('competition', 'enter')
      → Orchestrator renders the target app with extraParams: { pageId: 'enter', ref: 'widget' }
      → Current collectionId, productId, proofId, theme all preserved automatically
    

    Backward Compatibility

    The onNavigate callback accepts both structured NavigationRequest objects and legacy strings. Existing widgets that call onNavigate('/some-path') continue to work — the portal treats plain strings as legacy no-ops and logs them for debugging.

    New widgets should always use the structured NavigationRequest format.

    onNavigate vs publicPortalUrl

    Widgets support two navigation patterns:

    onNavigate (parent-controlled, recommended)

    • Parent provides a callback that the orchestrator interprets
    • Widget emits a structured NavigationRequest
    • Portal handles hierarchy transitions, context preservation, and routing

    publicPortalUrl (direct redirect, escape hatch)

    • Widget knows the full URL to the public portal
    • Uses SL.iframe.redirectParent() for navigation
    • Automatically handles iframe escape via postMessage
    • Useful when widget needs to break out of nested iframes

    Priority: If both are provided, onNavigate takes precedence.

    // Recommended: structured navigation
    <MyWidget
      onNavigate={(request) => {
        // Portal orchestrator handles this automatically
        // when using ContentOrchestrator / OrchestratedPortal
      }}
    />
    
    // Escape hatch: direct redirect
    <MyWidget
      publicPortalUrl="https://my-app.smartlinks.io"
    />
    

    Building a Widget


    Widget Instance Resolution

    Some apps expose a widget resolver instead of a single hard-coded widget. The resolver receives a widgetId, looks up a stored instance in app config, and renders the correct widget with its saved settings.

    This pattern is useful for widget toolkits, promo blocks, countdown libraries, CTA collections, and any app where admins create multiple reusable widget instances.

    URL and embed convention

    Use widgetId the same way page-oriented apps use pageId:

    ?appId=widget-toolkit&widgetId=launch-countdown
    

    This works naturally in:

    • direct container or iframe links
    • widget-to-widget references
    • content slots that need to embed a preconfigured widget instance
    • platform deep-link routing when the manifest declares widget instance resolution

    Manifest declaration

    Declare support in app.manifest.json:

    {
      "widgets": {
        "instanceResolution": true,
        "instanceParam": "widgetId",
        "files": {
          "js": {
            "umd": "dist/widgets.umd.js",
            "esm": "dist/widgets.es.js"
          },
          "css": null
        },
        "components": [
          {
            "name": "WidgetToolkitResolver",
            "description": "Resolves and renders widget instances by ID."
          }
        ]
      }
    }
    

    SDK helpers

    The SDK now includes two thin helpers on SL.appConfiguration:

    const widget = await SL.appConfiguration.getWidgetInstance({
      collectionId,
      appId: 'widget-toolkit',
      widgetId: 'launch-countdown',
    })
    
    const widgets = await SL.appConfiguration.listWidgetInstances({
      collectionId,
      appId: 'widget-toolkit',
    })
    

    getWidgetInstance() is intentionally just a wrapper over getConfig() that reads config.widgets[widgetId]. That keeps the integration simple today while giving the platform freedom to optimize the lookup later.

    Stored config shape

    The recommended storage shape is:

    {
      "widgets": {
        "launch-countdown": {
          "id": "launch-countdown",
          "name": "Launch Countdown",
          "widget": {
            "type": "countdown",
            "targetDate": "2026-06-01T00:00:00Z",
            "label": "Launching in..."
          }
        }
      }
    }
    

    Resolver pattern

    const WidgetToolkitResolver: React.FC<SmartLinksWidgetProps & { widgetId?: string }> = ({
      collectionId,
      appId,
      widgetId,
      SL,
      ...props
    }) => {
      const [instance, setInstance] = React.useState<any | null>(null)
    
      React.useEffect(() => {
        if (!widgetId) return
        SL.appConfiguration.getWidgetInstance({ collectionId, appId, widgetId })
          .then(setInstance)
          .catch(console.error)
      }, [collectionId, appId, widgetId, SL])
    
      if (!widgetId || !instance) return null
    
      switch (instance.widget?.type) {
        case 'countdown':
          return <CountdownWidget {...props} {...instance.widget} />
        case 'cta-button':
          return <CtaButtonWidget {...props} {...instance.widget} />
        default:
          return null
      }
    }
    

    Listing widget instances

    listWidgetInstances() is useful for picker UIs and cross-app references:

    const options = await SL.appConfiguration.listWidgetInstances({
      collectionId,
      appId: 'widget-toolkit',
    })
    
    // [{ id: 'launch-countdown', name: 'Launch Countdown', type: 'countdown' }]
    

    This is a good foundation for future admin question types or dynamic selects, but the SDK does not currently define a built-in dynamic-select schema field.

    1. Create the Widget Component

    // src/widgets/MyWidget/MyWidget.tsx
    import { SmartLinksWidgetProps } from '../types';
    import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
    import { Button } from '@/components/ui/button';
    
    export const MyWidget: React.FC<SmartLinksWidgetProps> = ({
      collectionId,
      appId,
      productId,
      user,
      SL,
      onNavigate,
      size = 'standard'
    }) => {
      // Use the SL SDK for API calls
      const handleAction = async () => {
        const data = await SL.appConfiguration.getConfig({
          collectionId,
          appId
        });
        console.log('Config:', data);
      };
    
      // Navigate to another app using structured request
      const handleOpenFullApp = () => {
        onNavigate?.({
          appId,
          deepLink: 'details',
          params: { source: 'widget' },
        });
      };
    
      return (
        <Card>
          <CardHeader>
            <CardTitle>My Widget</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-muted-foreground mb-4">
              Widget content goes here
            </p>
            <Button onClick={handleOpenFullApp}>Open App</Button>
          </CardContent>
        </Card>
      );
    };
    

    2. Create the Index File

    // src/widgets/MyWidget/index.tsx
    export { MyWidget } from './MyWidget';
    

    3. Export from Widget Barrel

    // src/widgets/index.ts
    export { MyWidget } from './MyWidget';
    
    // Update the manifest
    export const WIDGET_MANIFEST = {
      version: '1.0.0',
      reactVersion: '18.x',
      widgets: [
        // ... existing widgets
        {
          name: 'MyWidget',
          description: 'Description of my widget',
          sizes: ['compact', 'standard', 'large']
        }
      ]
    };
    

    Size Modes

    Widgets should support three size modes:

    SizeUse CaseTypical Height
    compactSidebar, small spaces80-120px
    standardDefault widget display150-250px
    largeFeatured/expanded view300px+
    const MyWidget: React.FC<SmartLinksWidgetProps> = ({ size = 'standard' }) => {
      const isCompact = size === 'compact';
      const isLarge = size === 'large';
    
      return (
        <Card className={isCompact ? 'p-2' : ''}>
          <CardHeader className={isCompact ? 'pb-2' : ''}>
            <CardTitle className={isCompact ? 'text-sm' : 'text-lg'}>
              Widget Title
            </CardTitle>
          </CardHeader>
          <CardContent>
            {isLarge && (
              <p>Additional content shown only in large mode</p>
            )}
            <Button size={isCompact ? 'sm' : 'default'}>
              Action
            </Button>
          </CardContent>
        </Card>
      );
    };
    

    Building Widget Bundle

    The project includes a separate Vite config for building widgets:

    # Build widgets only
    vite build --config vite.config.widget.ts
    
    # Output: dist/widgets.es.js
    

    Build Configuration

    The widget build:

    • Outputs ESM format for modern browsers
    • Externalizes dependencies the parent already provides (see below)
    • Generates source maps for debugging
    • Minifies with esbuild for production
    • Outputs to /dist alongside the main app (not a separate folder)

    Externalized Dependencies (Peer Dependencies)

    The widget bundle does not include these libraries—the parent app must provide them:

    PackageWhy Externalized
    react, react-domParent's React context
    @proveanything/smartlinksPassed via props as SL
    tailwind-mergeUtility for merging Tailwind classes
    clsxUtility for conditional class names
    class-variance-authorityUtility for component variants

    These are standard packages that any modern React + Tailwind app will have. Externalizing them:

    1. Reduces bundle size significantly
    2. Removes JSDoc comments that inflate the bundle
    3. Ensures consistent behavior with parent's versions

    Enabling Widget Builds

    Widget builds are disabled by default to keep builds fast. To enable:

    1. Add to .env:

      VITE_ENABLE_WIDGETS=true
      
    2. The build script should be: vite build && vite build --config vite.config.widget.ts

    If VITE_ENABLE_WIDGETS is not set to true, the build produces a minimal stub file and skips quickly.


    Parent Integration

    Dynamic Import

    // In parent SmartLinks portal
    import { lazy, Suspense } from 'react';
    import * as SL from '@proveanything/smartlinks';
    
    // Dynamic import from app's CDN
    const CompetitionWidget = lazy(() => 
      import('https://competition-app.example.com/widgets.es.js')
        .then(m => ({ default: m.CompetitionWidget }))
    );
    
    function Portal() {
      return (
        <Suspense fallback={<WidgetSkeleton />}>
          <CompetitionWidget
            collectionId="abc123"
            appId="competition"
            user={currentUser}
            SL={SL}
            onNavigate={(request) => {
              // The portal orchestrator handles NavigationRequest objects
              // automatically when using ContentOrchestrator / OrchestratedPortal
            }}
            size="standard"
          />
        </Suspense>
      );
    }
    

    Using WidgetWrapper

    The WidgetWrapper component provides error boundary and loading handling:

    import { WidgetWrapper, CompetitionWidget } from 'competition-app/widgets';
    
    <WidgetWrapper
      loading={<CustomLoader />}
      error={<CustomError />}
    >
      <CompetitionWidget {...props} />
    </WidgetWrapper>
    

    Checking Compatibility

    import { WIDGET_MANIFEST } from 'competition-app/widgets';
    
    // Verify React version compatibility
    if (!WIDGET_MANIFEST.reactVersion.startsWith('18')) {
      console.warn('Widget React version mismatch');
    }
    
    // List available widgets
    console.log('Available widgets:', WIDGET_MANIFEST.widgets);
    

    Styling

    Widgets inherit styling from the parent application via CSS variables:

    /* Parent defines these in their index.css */
    :root {
      --background: 0 0% 100%;
      --foreground: 222.2 84% 4.9%;
      --primary: 222.2 47.4% 11.2%;
      --muted: 210 40% 96.1%;
      /* ... etc */
    }
    

    Widgets use semantic class names that reference these variables:

    // ✅ DO: Use semantic classes
    <div className="bg-background text-foreground">
    <p className="text-muted-foreground">
    <Button className="bg-primary text-primary-foreground">
    
    // ❌ DON'T: Use hardcoded colors
    <div className="bg-white text-black">
    <p className="text-gray-500">
    <Button className="bg-blue-500 text-white">
    

    Best Practices

    Do's

    • ✅ Keep widgets focused and single-purpose
    • ✅ Support all three size modes
    • ✅ Use semantic color classes for theming
    • ✅ Handle loading and error states gracefully
    • ✅ Use the provided SL SDK for API calls
    • ✅ Use structured NavigationRequest for cross-app navigation
    • ✅ Include params for any extra context the target app needs

    Don'ts

    • ❌ Don't bundle React or SmartLinks SDK
    • ❌ Don't use hardcoded colors
    • ❌ Don't assume specific viewport sizes
    • ❌ Don't make widgets too complex (use full app for that)
    • ❌ Don't store state that should persist (use parent or SDK)
    • ❌ Don't construct portal URLs manually — use NavigationRequest instead

    Widget Manifest

    Each app exports a WIDGET_MANIFEST for discovery:

    export const WIDGET_MANIFEST = {
      version: '1.0.0',        // Widget bundle version
      reactVersion: '18.x',    // Required React version
      widgets: [
        {
          name: 'ExampleWidget',
          description: 'Demo widget showing SmartLinks integration',
          sizes: ['compact', 'standard', 'large']
        }
      ]
    };
    

    Parent applications can use this manifest to:

    • Verify version compatibility
    • Discover available widgets
    • Show widget descriptions in UI

    Testing Widgets Locally

    During development, you can test widgets within the app itself:

    // In a development page
    import { ExampleWidget } from '@/widgets';
    import * as SL from '@proveanything/smartlinks';
    
    function WidgetTestPage() {
      return (
        <div className="p-8 space-y-8">
          <h2>Compact</h2>
          <ExampleWidget
            collectionId="test"
            appId="example"
            SL={SL}
            size="compact"
          />
          
          <h2>Standard</h2>
          <ExampleWidget
            collectionId="test"
            appId="example"
            SL={SL}
            size="standard"
          />
          
          <h2>Large</h2>
          <ExampleWidget
            collectionId="test"
            appId="example"
            SL={SL}
            size="large"
          />
        </div>
      );
    }
    

    File Structure

    src/widgets/
    ├── index.ts              # Main exports barrel
    ├── types.ts              # SmartLinksWidgetProps, NavigationRequest, and related types
    ├── WidgetWrapper.tsx     # Error boundary + Suspense wrapper
    └── ExampleWidget/
        ├── index.tsx         # Re-export
        └── ExampleWidget.tsx # Widget implementation
    

    When creating new widgets, follow this structure:

    1. Create a folder: src/widgets/MyWidget/
    2. Add component: MyWidget.tsx
    3. Add re-export: index.tsx
    4. Export from: src/widgets/index.ts
    5. Add to: WIDGET_MANIFEST