@proveanything/smartlinks
Version:
Official JavaScript/TypeScript SDK for the Smartlinks API
579 lines (460 loc) • 16.9 kB
Markdown
# 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
```text
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Widget Props Interface
All widgets receive the `SmartLinksWidgetProps` interface:
```typescript
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
| Prop | Type | Description |
|------|------|-------------|
| `collectionId` | `string` | The collection context (required) |
| `appId` | `string` | This app's identifier (required) |
| `productId` | `string?` | Optional product context |
| `proofId` | `string?` | Optional proof context |
| `user` | `object?` | Current user info if authenticated |
| `SL` | `typeof SL` | Pre-initialized SmartLinks SDK |
| `onNavigate` | `function?` | Callback to navigate within parent app (accepts `NavigationRequest` or legacy string) |
| `publicPortalUrl` | `string?` | Base URL to full portal for deep links |
| `size` | `string?` | Size hint: 'compact', 'standard', or 'large' |
| `lang` | `string?` | Language code (e.g., 'en', 'de', 'fr') |
| `translations` | `object?` | 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
```typescript
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
```typescript
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
```text
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.
```typescript
// 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
### 1. Create the Widget Component
```typescript
// 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
```typescript
// src/widgets/MyWidget/index.tsx
export { MyWidget } from './MyWidget';
```
### 3. Export from Widget Barrel
```typescript
// 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:
| Size | Use Case | Typical Height |
|------|----------|----------------|
| `compact` | Sidebar, small spaces | 80-120px |
| `standard` | Default widget display | 150-250px |
| `large` | Featured/expanded view | 300px+ |
```typescript
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:
```bash
# 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:
| Package | Why Externalized |
|---------|------------------|
| `react`, `react-dom` | Parent's React context |
| `@proveanything/smartlinks` | Passed via props as `SL` |
| `tailwind-merge` | Utility for merging Tailwind classes |
| `clsx` | Utility for conditional class names |
| `class-variance-authority` | Utility 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
```typescript
// 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:
```typescript
import { WidgetWrapper, CompetitionWidget } from 'competition-app/widgets';
<WidgetWrapper
loading={<CustomLoader />}
error={<CustomError />}
>
<CompetitionWidget {...props} />
</WidgetWrapper>
```
### Checking Compatibility
```typescript
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:
```css
/* 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:
```tsx
// ✅ 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:
```typescript
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:
```typescript
// 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`