@proveanything/smartlinks 1.11.11 → 1.11.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/navigation.d.ts +24 -0
- package/dist/api/navigation.js +156 -0
- package/dist/docs/API_SUMMARY.md +58 -1
- package/dist/docs/deep-link-discovery.md +238 -15
- package/dist/openapi.yaml +22 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/navigation.d.ts +121 -0
- package/dist/types/navigation.js +1 -0
- package/docs/API_SUMMARY.md +58 -1
- package/docs/deep-link-discovery.md +238 -15
- package/openapi.yaml +22 -0
- package/package.json +1 -1
package/dist/api/index.d.ts
CHANGED
package/dist/api/index.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LinkTarget, ResolveLinkContext, ResolvedLink } from '../types/navigation';
|
|
2
|
+
export declare namespace navigation {
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a stored `LinkTarget` into an executable navigation action.
|
|
5
|
+
*
|
|
6
|
+
* The resolver handles the embedded/standalone distinction automatically:
|
|
7
|
+
*
|
|
8
|
+
* | Kind | Embedded (container/widget/iframe) | Standalone |
|
|
9
|
+
* |-------------------|---------------------------------------------------------------|-------------------|
|
|
10
|
+
* | `external _blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same |
|
|
11
|
+
* | `external _self` | `window.location.assign(url)` | same |
|
|
12
|
+
* | `app` / `deep` | `postMessage` to parent shell; shell appends context params | hash-route assign |
|
|
13
|
+
*
|
|
14
|
+
* Context params (`collectionId`, `productId`, `proofId`, etc.) are **not**
|
|
15
|
+
* included in the message payload — the parent SmartLinks shell owns them and
|
|
16
|
+
* appends them before broadcasting the navigation event.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const r = SL.navigation.resolveLink(config.ctaLink);
|
|
20
|
+
* r.navigate(); // perform navigation
|
|
21
|
+
* button.ariaLabel = r.describe(); // human-readable label
|
|
22
|
+
*/
|
|
23
|
+
function resolveLink(link: LinkTarget, ctx?: ResolveLinkContext): ResolvedLink;
|
|
24
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/api/navigation.ts
|
|
2
|
+
import { analytics } from './analytics';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Internal helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function detectEmbedded() {
|
|
7
|
+
if (typeof window === 'undefined')
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
return window.parent !== window;
|
|
11
|
+
}
|
|
12
|
+
catch (_a) {
|
|
13
|
+
// Cross-origin parent — access throws, which means we are embedded.
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Normalise a `deepLinkId` to a hash-route path.
|
|
19
|
+
* `deepLinkId` is either a bare path ("/winners") or a "path?params" string
|
|
20
|
+
* as minted from manifest entries. Strip any inline query string — params are
|
|
21
|
+
* forwarded separately.
|
|
22
|
+
*/
|
|
23
|
+
function deepPath(deepLinkId) {
|
|
24
|
+
const [path] = deepLinkId.split('?');
|
|
25
|
+
if (!path)
|
|
26
|
+
return '/';
|
|
27
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
28
|
+
}
|
|
29
|
+
function qs(params) {
|
|
30
|
+
if (!params)
|
|
31
|
+
return '';
|
|
32
|
+
const keys = Object.keys(params);
|
|
33
|
+
if (!keys.length)
|
|
34
|
+
return '';
|
|
35
|
+
const usp = new URLSearchParams();
|
|
36
|
+
for (const k of keys)
|
|
37
|
+
usp.set(k, params[k]);
|
|
38
|
+
return `?${usp.toString()}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the window-features string for `window.open`.
|
|
42
|
+
* `noopener` and `noreferrer` are always present; a caller-supplied `rel`
|
|
43
|
+
* can add extra features (e.g. `'popup'`) which are appended after the
|
|
44
|
+
* mandatory security flags.
|
|
45
|
+
*/
|
|
46
|
+
function windowFeatures(rel) {
|
|
47
|
+
const base = 'noopener,noreferrer';
|
|
48
|
+
if (!rel)
|
|
49
|
+
return base;
|
|
50
|
+
// Avoid duplicating the security flags if the caller already included them.
|
|
51
|
+
const extra = rel
|
|
52
|
+
.split(',')
|
|
53
|
+
.map(f => f.trim())
|
|
54
|
+
.filter(f => f && f !== 'noopener' && f !== 'noreferrer')
|
|
55
|
+
.join(',');
|
|
56
|
+
return extra ? `${base},${extra}` : base;
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Public namespace
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export var navigation;
|
|
62
|
+
(function (navigation) {
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a stored `LinkTarget` into an executable navigation action.
|
|
65
|
+
*
|
|
66
|
+
* The resolver handles the embedded/standalone distinction automatically:
|
|
67
|
+
*
|
|
68
|
+
* | Kind | Embedded (container/widget/iframe) | Standalone |
|
|
69
|
+
* |-------------------|---------------------------------------------------------------|-------------------|
|
|
70
|
+
* | `external _blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same |
|
|
71
|
+
* | `external _self` | `window.location.assign(url)` | same |
|
|
72
|
+
* | `app` / `deep` | `postMessage` to parent shell; shell appends context params | hash-route assign |
|
|
73
|
+
*
|
|
74
|
+
* Context params (`collectionId`, `productId`, `proofId`, etc.) are **not**
|
|
75
|
+
* included in the message payload — the parent SmartLinks shell owns them and
|
|
76
|
+
* appends them before broadcasting the navigation event.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* const r = SL.navigation.resolveLink(config.ctaLink);
|
|
80
|
+
* r.navigate(); // perform navigation
|
|
81
|
+
* button.ariaLabel = r.describe(); // human-readable label
|
|
82
|
+
*/
|
|
83
|
+
function resolveLink(link, ctx = {}) {
|
|
84
|
+
var _a, _b;
|
|
85
|
+
const win = (_a = ctx.win) !== null && _a !== void 0 ? _a : (typeof window !== 'undefined' ? window : undefined);
|
|
86
|
+
const embedded = (_b = ctx.embedded) !== null && _b !== void 0 ? _b : detectEmbedded();
|
|
87
|
+
function fireTracking(resolved) {
|
|
88
|
+
if (!ctx.track)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
analytics.browser.trackLinkClick(Object.assign({
|
|
92
|
+
// Derived from the LinkTarget — caller overrides win if needed.
|
|
93
|
+
href: link.kind === 'external' ? link.url : '', isExternal: link.kind === 'external', destinationAppId: link.kind !== 'external' ? link.appId : undefined, linkTitle: resolved.describe() }, ctx.track));
|
|
94
|
+
}
|
|
95
|
+
catch (_a) {
|
|
96
|
+
// Tracking must never prevent navigation.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const resolved = {
|
|
100
|
+
navigate() {
|
|
101
|
+
var _a, _b, _c;
|
|
102
|
+
if (!win)
|
|
103
|
+
return;
|
|
104
|
+
fireTracking(resolved);
|
|
105
|
+
// External links are always handled the same way regardless of context.
|
|
106
|
+
if (link.kind === 'external') {
|
|
107
|
+
if (link.target === '_blank') {
|
|
108
|
+
win.open(link.url, '_blank', windowFeatures(link.rel));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
win.location.assign(link.url);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (embedded) {
|
|
116
|
+
// Post a navigation request to the parent SmartLinks shell. The shell
|
|
117
|
+
// will attach collectionId, productId, proofId, etc. before acting.
|
|
118
|
+
const postTarget = (_a = ctx.postTarget) !== null && _a !== void 0 ? _a : win.parent;
|
|
119
|
+
if (!postTarget)
|
|
120
|
+
return;
|
|
121
|
+
postTarget.postMessage({
|
|
122
|
+
type: 'smartlinks-navigate',
|
|
123
|
+
appId: link.appId,
|
|
124
|
+
path: link.kind === 'deep' ? deepPath(link.deepLinkId) : '/',
|
|
125
|
+
params: link.kind === 'deep' ? ((_b = link.params) !== null && _b !== void 0 ? _b : {}) : {},
|
|
126
|
+
target: (_c = link.target) !== null && _c !== void 0 ? _c : '_self',
|
|
127
|
+
}, '*');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Standalone fallback — construct a best-effort hash route. This path
|
|
131
|
+
// is intentionally minimal: the canonical URL shape is owned by the
|
|
132
|
+
// platform shell. This is only used when no parent shell is present
|
|
133
|
+
// (e.g. deep-linking from a server-rendered page).
|
|
134
|
+
const hash = link.kind === 'deep'
|
|
135
|
+
? `#${deepPath(link.deepLinkId)}${qs(link.params)}`
|
|
136
|
+
: `#/`;
|
|
137
|
+
const url = `${win.location.pathname}?appId=${encodeURIComponent(link.appId)}${hash}`;
|
|
138
|
+
if (link.target === '_blank') {
|
|
139
|
+
win.open(url, '_blank', windowFeatures());
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
win.location.assign(url);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
describe() {
|
|
146
|
+
if (link.kind === 'external')
|
|
147
|
+
return `Open ${link.url}`;
|
|
148
|
+
if (link.kind === 'app')
|
|
149
|
+
return `Open app ${link.appId}`;
|
|
150
|
+
return `Open ${link.appId} \u2192 ${link.deepLinkId}`;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return resolved;
|
|
154
|
+
}
|
|
155
|
+
navigation.resolveLink = resolveLink;
|
|
156
|
+
})(navigation || (navigation = {}));
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.13 | Generated: 2026-05-05T09:55:20.018Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -110,6 +110,7 @@ The Smartlinks SDK is organized into the following namespaces:
|
|
|
110
110
|
- **jobs** - Functions for jobs operations
|
|
111
111
|
- **journeysAnalytics** - Functions for journeysAnalytics operations
|
|
112
112
|
- **location** - Functions for location operations
|
|
113
|
+
- **navigation** - Functions for navigation operations
|
|
113
114
|
- **order** - Functions for order operations
|
|
114
115
|
- **products** - Functions for products operations
|
|
115
116
|
- **realtime** - Functions for realtime operations
|
|
@@ -5669,6 +5670,57 @@ interface RecordLoyaltyTransactionBody {
|
|
|
5669
5670
|
|
|
5670
5671
|
**DataBlock** = `Record<string, unknown>`
|
|
5671
5672
|
|
|
5673
|
+
### navigation
|
|
5674
|
+
|
|
5675
|
+
**ResolveLinkContext** (interface)
|
|
5676
|
+
```typescript
|
|
5677
|
+
interface ResolveLinkContext {
|
|
5678
|
+
* True when running inside a SmartLinks container, widget, or iframe.
|
|
5679
|
+
* Defaults to auto-detection via `window.parent !== window`.
|
|
5680
|
+
embedded?: boolean;
|
|
5681
|
+
* Override for the `postMessage` target window.
|
|
5682
|
+
* Defaults to `window.parent`. Useful in tests and hosts that proxy messages.
|
|
5683
|
+
postTarget?: Window | null;
|
|
5684
|
+
* Override for the navigation window.
|
|
5685
|
+
* Defaults to `window`. Useful in tests.
|
|
5686
|
+
win?: Window;
|
|
5687
|
+
* When provided, `resolveLink` automatically fires a `click_link` analytics
|
|
5688
|
+
* event via `SL.analytics.browser.trackLinkClick` immediately before
|
|
5689
|
+
* navigating. Supply at minimum `collectionId`; add `productId`, `proofId`,
|
|
5690
|
+
* or any other `CollectionAnalyticsEvent` fields you want on the event.
|
|
5691
|
+
*
|
|
5692
|
+
* The resolver derives `isExternal`, `destinationAppId`, `linkTitle`, and
|
|
5693
|
+
* `href` from the `LinkTarget` automatically. Fields you supply here take
|
|
5694
|
+
* precedence over the derived values if there is a conflict.
|
|
5695
|
+
*
|
|
5696
|
+
* Called synchronously so the event fires even for external `_blank` links
|
|
5697
|
+
* that unload the page immediately after.
|
|
5698
|
+
*
|
|
5699
|
+
* @example
|
|
5700
|
+
* SL.navigation.resolveLink(link, {
|
|
5701
|
+
* track: { collectionId, productId },
|
|
5702
|
+
* });
|
|
5703
|
+
track?: LinkTrackingContext;
|
|
5704
|
+
}
|
|
5705
|
+
```
|
|
5706
|
+
|
|
5707
|
+
**ResolvedLink** (interface)
|
|
5708
|
+
```typescript
|
|
5709
|
+
interface ResolvedLink {
|
|
5710
|
+
navigate(): void;
|
|
5711
|
+
describe(): string;
|
|
5712
|
+
}
|
|
5713
|
+
```
|
|
5714
|
+
|
|
5715
|
+
**LinkOpenTarget** = `'_self' | '_blank'`
|
|
5716
|
+
|
|
5717
|
+
**LinkTarget** = ``
|
|
5718
|
+
|
|
5719
|
+
**LinkTrackingContext** (type)
|
|
5720
|
+
```typescript
|
|
5721
|
+
type LinkTrackingContext = { collectionId: string }
|
|
5722
|
+
```
|
|
5723
|
+
|
|
5672
5724
|
### nfc
|
|
5673
5725
|
|
|
5674
5726
|
**NfcTagInfo** (interface)
|
|
@@ -8560,6 +8612,11 @@ List available AI models
|
|
|
8560
8612
|
**get**(collectionId: string, modelId: string) → `Promise<AIModel>`
|
|
8561
8613
|
Get specific model information
|
|
8562
8614
|
|
|
8615
|
+
### navigation
|
|
8616
|
+
|
|
8617
|
+
**resolveLink**(link: LinkTarget, ctx: ResolveLinkContext = {}) → `ResolvedLink`
|
|
8618
|
+
Resolve a stored `LinkTarget` into an executable navigation action. The resolver handles the embedded/standalone distinction automatically: | Kind | Embedded (container/widget/iframe) | Standalone | |-------------------|---------------------------------------------------------------|-------------------| | `external _blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same | | `external _self` | `window.location.assign(url)` | same | | `app` / `deep` | `postMessage` to parent shell; shell appends context params | hash-route assign | Context params (`collectionId`, `productId`, `proofId`, etc.) are **not** included in the message payload — the parent SmartLinks shell owns them and appends them before broadcasting the navigation event. const r = SL.navigation.resolveLink(config.ctaLink); r.navigate(); // perform navigation button.ariaLabel = r.describe(); // human-readable label
|
|
8619
|
+
|
|
8563
8620
|
### nfc
|
|
8564
8621
|
|
|
8565
8622
|
**claimTag**(data: NfcClaimTagRequest) → `Promise<NfcTagInfo>`
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Complete guide to registering and discovering navigable states in SmartLinks apps, enabling portal menus, AI orchestrators, and other apps to deep-link directly into specific views.
|
|
4
4
|
|
|
5
|
+
This doc covers both sides of the contract: **suppliers** declare their navigable states in the manifest and app config; **consumers** discover those states at runtime, present them in a `LinkPicker` (admin), and resolve them with `SL.navigation.resolveLink` (public widgets and executors). External URLs and in-platform deep links are both first-class `LinkTarget` variants — a single type persisted, a single resolver called at the point of navigation.
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Table of Contents
|
|
@@ -19,8 +21,12 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
19
21
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
20
22
|
- [AI Orchestration](#ai-orchestration)
|
|
21
23
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
24
|
+
- [Link Picker — Admin Configuration](#link-picker--admin-configuration)
|
|
25
|
+
- [Rendering Links at Runtime](#rendering-links-at-runtime)
|
|
22
26
|
- [TypeScript Types](#typescript-types)
|
|
23
|
-
- [
|
|
27
|
+
- [Supplier Responsibilities](#supplier-responsibilities)
|
|
28
|
+
- [Consumer Responsibilities](#consumer-responsibilities)
|
|
29
|
+
- [Anti-Patterns](#anti-patterns)
|
|
24
30
|
|
|
25
31
|
---
|
|
26
32
|
|
|
@@ -72,6 +78,34 @@ Consumers merge both sources to get the full set of navigable states for an app.
|
|
|
72
78
|
|
|
73
79
|
---
|
|
74
80
|
|
|
81
|
+
## Two Roles: Suppliers and Consumers
|
|
82
|
+
|
|
83
|
+
Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
|
|
84
|
+
|
|
85
|
+
| Role | What you do | Primary surface |
|
|
86
|
+
|------|-------------|-----------------|
|
|
87
|
+
| **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
|
|
88
|
+
| **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
|
|
89
|
+
|
|
90
|
+
Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
|
|
91
|
+
|
|
92
|
+
### Supplier checklist
|
|
93
|
+
|
|
94
|
+
- [ ] Static navigable views declared in `app.manifest.json#linkable`
|
|
95
|
+
- [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
|
|
96
|
+
- [ ] Every entry has a concise, human-readable `title`
|
|
97
|
+
|
|
98
|
+
### Consumer checklist
|
|
99
|
+
|
|
100
|
+
- [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
|
|
101
|
+
- [ ] Merges `manifest.linkable` + `config.linkable` to present navigable states in the picker
|
|
102
|
+
- [ ] Persists the admin's choice as a `LinkTarget` discriminated union — never a raw URL string
|
|
103
|
+
- [ ] Renders / navigates using `SL.navigation.resolveLink(link)` — never `window.open` or `<a href>` directly
|
|
104
|
+
- [ ] Uses `<LinkPicker />` from `@proveanything/smartlinks-utils-ui` in admin — never a free-text field for app IDs or URLs
|
|
105
|
+
- [ ] External URLs are configured through `LinkPicker` too (kind: `'external'`), not a separate field
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
75
109
|
## Two Sources of Truth
|
|
76
110
|
|
|
77
111
|
### Static Links — App Manifest
|
|
@@ -462,6 +496,143 @@ if (entry) {
|
|
|
462
496
|
}
|
|
463
497
|
```
|
|
464
498
|
|
|
499
|
+
`onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
### Link Picker — Admin Configuration
|
|
504
|
+
|
|
505
|
+
When an admin needs to configure any outbound link — to another installed app, a specific page within an app, or an external URL — **never use a bare text input**. Use `<LinkPicker />` from `@proveanything/smartlinks-utils-ui`. It handles all three `LinkTarget` kinds and produces a single `LinkTarget` value to persist.
|
|
506
|
+
|
|
507
|
+
```tsx
|
|
508
|
+
import { LinkPicker, type LinkTarget } from '@proveanything/smartlinks-utils-ui';
|
|
509
|
+
|
|
510
|
+
<LinkPicker
|
|
511
|
+
collectionId={collectionId}
|
|
512
|
+
currentAppId={appId} // include self in the app list
|
|
513
|
+
value={config.ctaLink} // LinkTarget | null
|
|
514
|
+
onChange={(next) => setConfig({ ...config, ctaLink: next })}
|
|
515
|
+
label="Button destination"
|
|
516
|
+
helpText="Choose an installed app, a specific page, or an external URL."
|
|
517
|
+
/>
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
`LinkPicker` internally calls `SL.collection.getAppsConfig`, merges `manifest.linkable` + `config.linkable` for whichever app is selected, and lets the admin pick the link kind (external / app / deep link). You never need to build this UI yourself.
|
|
521
|
+
|
|
522
|
+
#### What `LinkPicker` produces
|
|
523
|
+
|
|
524
|
+
| Admin selects | `LinkTarget` stored |
|
|
525
|
+
|---------------|---------------------|
|
|
526
|
+
| External URL (opens in new tab) | `{ kind: 'external', url: '…', target: '_blank', rel: 'noopener noreferrer' }` |
|
|
527
|
+
| External URL (same tab) | `{ kind: 'external', url: '…', target: '_self' }` |
|
|
528
|
+
| An installed app, no specific page | `{ kind: 'app', appId: '…' }` |
|
|
529
|
+
| An installed app + specific page | `{ kind: 'deep', appId: '…', deepLinkId: '…', params: {…} }` |
|
|
530
|
+
|
|
531
|
+
#### How app/deep links are discovered inside the picker
|
|
532
|
+
|
|
533
|
+
The picker calls `SL.collection.getAppsConfig` and for the chosen app merges:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
const entries: DeepLinkEntry[] = [
|
|
537
|
+
...(app.manifest?.linkable ?? []),
|
|
538
|
+
...(app.config?.linkable ?? []),
|
|
539
|
+
];
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
If an app has no declared entries, the picker offers the `app` kind ("Open app — default route") so the admin can still configure a link to it.
|
|
543
|
+
|
|
544
|
+
> **`kind: 'app'` and discoverability:** An app linked via `kind: 'app'` opens to its default route. If you want that default landing to have a meaningful title in the picker and in AI orchestration, declare a `linkable` entry for `"/"` in your manifest — it will appear as an explicit option rather than the fallback.
|
|
545
|
+
|
|
546
|
+
#### Persisting the value
|
|
547
|
+
|
|
548
|
+
`LinkTarget` is the persistence type. Store it as-is in your app config — never convert to a URL string before saving.
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
// ✅ Correct
|
|
552
|
+
await SL.appConfiguration.setConfig({
|
|
553
|
+
collectionId, appId,
|
|
554
|
+
config: { ...config, ctaLink: linkTarget },
|
|
555
|
+
admin: true,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ❌ Wrong — you've thrown away the kind, params, and context-injection
|
|
559
|
+
await SL.appConfiguration.setConfig({
|
|
560
|
+
collectionId, appId,
|
|
561
|
+
config: { ...config, ctaUrl: resolvedUrl },
|
|
562
|
+
admin: true,
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
### Rendering Links at Runtime
|
|
569
|
+
|
|
570
|
+
In public widgets, executors, and any non-admin context, resolve a stored `LinkTarget` with `SL.navigation.resolveLink`. This is pure SDK — no React, no admin package dependency.
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
import * as SL from '@proveanything/smartlinks';
|
|
574
|
+
import type { LinkTarget } from '@proveanything/smartlinks';
|
|
575
|
+
|
|
576
|
+
const resolved = SL.navigation.resolveLink(link);
|
|
577
|
+
|
|
578
|
+
resolved.navigate(); // executes the navigation (postMessage, location.assign, or window.open)
|
|
579
|
+
resolved.describe(); // plain-text label for aria-label, logging, AI agent descriptions
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`resolveLink` handles the embedded/standalone distinction automatically:
|
|
583
|
+
|
|
584
|
+
| `LinkTarget.kind` | Inside container / iframe | Standalone (direct URL) |
|
|
585
|
+
|-------------------|--------------------------|-------------------------|
|
|
586
|
+
| `external` + `_blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same |
|
|
587
|
+
| `external` + `_self` | `window.location.assign(url)` | same |
|
|
588
|
+
| `app` / `deep` | `postMessage` to parent shell — shell appends `collectionId`, `productId`, `proofId`, etc. | constructs hash route locally and assigns/opens |
|
|
589
|
+
|
|
590
|
+
You never need to branch on `embedded` yourself — `resolveLink` reads the execution context.
|
|
591
|
+
|
|
592
|
+
#### React component pattern
|
|
593
|
+
|
|
594
|
+
```tsx
|
|
595
|
+
import * as SL from '@proveanything/smartlinks';
|
|
596
|
+
import type { LinkTarget } from '@proveanything/smartlinks';
|
|
597
|
+
|
|
598
|
+
function CTAButton({ link, label }: { link: LinkTarget; label: string }) {
|
|
599
|
+
const resolved = SL.navigation.resolveLink(link);
|
|
600
|
+
return (
|
|
601
|
+
<button type="button" onClick={() => resolved.navigate()} aria-label={resolved.describe()}>
|
|
602
|
+
{label}
|
|
603
|
+
</button>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
#### Worked example — Raffle "See the prizes" button (full round-trip)
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
// --- Admin app (uses LinkPicker from smartlinks-utils-ui) ---
|
|
612
|
+
import { LinkPicker, type LinkTarget } from '@proveanything/smartlinks-utils-ui';
|
|
613
|
+
|
|
614
|
+
<LinkPicker
|
|
615
|
+
collectionId={collectionId}
|
|
616
|
+
currentAppId={appId}
|
|
617
|
+
value={config.prizesLink}
|
|
618
|
+
onChange={(prizesLink) => setConfig({ ...config, prizesLink })}
|
|
619
|
+
label="Prizes page"
|
|
620
|
+
helpText="Link to a content app describing this raffle's prizes."
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
// --- Public widget (uses SL.navigation.resolveLink from @proveanything/smartlinks) ---
|
|
624
|
+
import * as SL from '@proveanything/smartlinks';
|
|
625
|
+
|
|
626
|
+
function PrizesButton({ link }: { link: LinkTarget }) {
|
|
627
|
+
const r = SL.navigation.resolveLink(link);
|
|
628
|
+
return (
|
|
629
|
+
<button type="button" onClick={() => r.navigate()} aria-label={r.describe()}>
|
|
630
|
+
See the prizes
|
|
631
|
+
</button>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
465
636
|
---
|
|
466
637
|
|
|
467
638
|
## TypeScript Types
|
|
@@ -489,28 +660,80 @@ export interface DeepLinkEntry {
|
|
|
489
660
|
|
|
490
661
|
/** Convenience alias for an array of DeepLinkEntry */
|
|
491
662
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
663
|
+
|
|
664
|
+
/** Where the link opens once resolved */
|
|
665
|
+
export type LinkOpenTarget = '_self' | '_blank';
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Discriminated union representing any link a `LinkPicker` can produce.
|
|
669
|
+
* This is the persistence type — store it as-is in app config; never convert to a URL.
|
|
670
|
+
*
|
|
671
|
+
* Resolved at render time with `SL.navigation.resolveLink(link)`.
|
|
672
|
+
*/
|
|
673
|
+
export type LinkTarget =
|
|
674
|
+
/** A URL outside the SmartLinks platform */
|
|
675
|
+
| { kind: 'external'; url: string; target: LinkOpenTarget; rel?: string }
|
|
676
|
+
/** Open an installed app at its default route */
|
|
677
|
+
| { kind: 'app'; appId: string; target?: LinkOpenTarget }
|
|
678
|
+
/** Open a specific deep-linkable page within an installed app */
|
|
679
|
+
| { kind: 'deep'; appId: string; deepLinkId: string;
|
|
680
|
+
params?: Record<string, string>; target?: LinkOpenTarget };
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The object returned by `SL.navigation.resolveLink`.
|
|
684
|
+
* `navigate()` performs the navigation; `describe()` returns a plain-text label
|
|
685
|
+
* suitable for aria-label attributes and AI agent descriptions.
|
|
686
|
+
*/
|
|
687
|
+
export interface ResolvedLink {
|
|
688
|
+
navigate(): void;
|
|
689
|
+
describe(): string;
|
|
690
|
+
}
|
|
492
691
|
```
|
|
493
692
|
|
|
494
693
|
---
|
|
495
694
|
|
|
496
|
-
##
|
|
695
|
+
## Supplier Responsibilities
|
|
497
696
|
|
|
498
|
-
|
|
697
|
+
Rules for apps that **expose** navigable states to the platform:
|
|
499
698
|
|
|
500
699
|
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
|
|
501
700
|
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
502
701
|
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
503
702
|
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
504
703
|
5. **`title` is required** — every entry must have a human-readable label.
|
|
505
|
-
6. **
|
|
506
|
-
7. **
|
|
507
|
-
8. **
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
704
|
+
6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
705
|
+
7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
706
|
+
8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## Consumer Responsibilities
|
|
711
|
+
|
|
712
|
+
Rules for apps that **navigate to** states in other installed apps or external URLs:
|
|
713
|
+
|
|
714
|
+
1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
|
|
715
|
+
2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
|
|
716
|
+
3. **Persist as `LinkTarget`** — store the discriminated union `{ kind, … }` as-is in your app config; never convert to a raw URL string before saving.
|
|
717
|
+
4. **Navigate with `SL.navigation.resolveLink`** — call `resolveLink(link).navigate()` for all outbound navigation; never call `window.open`, `window.location.assign`, or `<a href>` directly from app logic.
|
|
718
|
+
5. **Use `<LinkPicker />` in admin** — admin forms that configure any outbound link (internal or external) must use `LinkPicker` from `@proveanything/smartlinks-utils-ui`, never a free-text field.
|
|
719
|
+
6. **External URLs belong in `LinkPicker` too** — `kind: 'external'` is a first-class option, not a separate field. One picker, one stored shape, one resolver.
|
|
720
|
+
7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
721
|
+
8. **Show fallback for apps with no declared entries** — `LinkPicker` uses `kind: 'app'` (default route) so admins can still link to an app that hasn't declared any deep-linkable pages.
|
|
722
|
+
|
|
723
|
+
---
|
|
724
|
+
|
|
725
|
+
## Anti-Patterns
|
|
726
|
+
|
|
727
|
+
| ❌ Anti-pattern | ✅ Correct approach |
|
|
728
|
+
|----------------|---------------------|
|
|
729
|
+
| Free-text "Target app ID" field in admin UI | `<LinkPicker />` from `smartlinks-utils-ui` |
|
|
730
|
+
| Separate "URL" field next to internal link field | `LinkPicker` handles both; `kind: 'external'` is a first-class option |
|
|
731
|
+
| Hard-coded `https://…` links between apps in the same collection | `kind: 'deep'` / `kind: 'app'` resolved with `SL.navigation.resolveLink` |
|
|
732
|
+
| `window.open()` / `<a target="_blank">` called directly from app logic | `SL.navigation.resolveLink(link).navigate()` |
|
|
733
|
+
| Converting a `LinkTarget` to a URL string before saving | Persist the `LinkTarget` union; resolve at navigation time |
|
|
734
|
+
| Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
|
|
735
|
+
| Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
|
|
736
|
+
| Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
|
|
737
|
+
| Importing `LinkPicker` in a public widget or executor | `LinkPicker` is admin-only; use `SL.navigation.resolveLink` in public code |
|
|
738
|
+
| Re-implementing picker logic per app | Use `<LinkPicker />` from `@proveanything/smartlinks-utils-ui` |
|
|
739
|
+
|
package/dist/openapi.yaml
CHANGED
|
@@ -21044,6 +21044,28 @@ components:
|
|
|
21044
21044
|
type: string
|
|
21045
21045
|
required:
|
|
21046
21046
|
- points
|
|
21047
|
+
ResolveLinkContext:
|
|
21048
|
+
type: object
|
|
21049
|
+
properties:
|
|
21050
|
+
embedded:
|
|
21051
|
+
type: boolean
|
|
21052
|
+
postTarget:
|
|
21053
|
+
$ref: "#/components/schemas/Window"
|
|
21054
|
+
win:
|
|
21055
|
+
$ref: "#/components/schemas/Window"
|
|
21056
|
+
track:
|
|
21057
|
+
$ref: "#/components/schemas/LinkTrackingContext"
|
|
21058
|
+
ResolvedLink:
|
|
21059
|
+
type: object
|
|
21060
|
+
properties: {}
|
|
21061
|
+
LinkOpenTarget:
|
|
21062
|
+
type: string
|
|
21063
|
+
enum:
|
|
21064
|
+
- _self
|
|
21065
|
+
- _blank
|
|
21066
|
+
LinkTrackingContext:
|
|
21067
|
+
type: object
|
|
21068
|
+
properties: {}
|
|
21047
21069
|
NfcTagInfo:
|
|
21048
21070
|
type: object
|
|
21049
21071
|
properties:
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { CollectionAnalyticsEvent } from './analytics';
|
|
2
|
+
/** Where the link opens once resolved. */
|
|
3
|
+
export type LinkOpenTarget = '_self' | '_blank';
|
|
4
|
+
/**
|
|
5
|
+
* Discriminated union representing any outbound link a `LinkPicker` can produce.
|
|
6
|
+
*
|
|
7
|
+
* This is the **persistence type** — store as-is in app config; never convert to a
|
|
8
|
+
* URL string before saving. Resolve at navigation time with `SL.navigation.resolveLink`.
|
|
9
|
+
*
|
|
10
|
+
* - `external` — a URL outside the SmartLinks platform (supports same-tab or new-tab)
|
|
11
|
+
* - `app` — open an installed app at its default route
|
|
12
|
+
* - `deep` — open a specific deep-linkable page within an installed app
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // External link
|
|
16
|
+
* { kind: 'external', url: 'https://example.com', target: '_blank' }
|
|
17
|
+
*
|
|
18
|
+
* // Open an app at its default route
|
|
19
|
+
* { kind: 'app', appId: 'raffle' }
|
|
20
|
+
*
|
|
21
|
+
* // Open a specific page within an app
|
|
22
|
+
* { kind: 'deep', appId: 'prizes', deepLinkId: '/winners', params: { year: '2026' } }
|
|
23
|
+
*/
|
|
24
|
+
export type LinkTarget = {
|
|
25
|
+
kind: 'external';
|
|
26
|
+
/** Absolute URL to navigate to. */
|
|
27
|
+
url: string;
|
|
28
|
+
target: LinkOpenTarget;
|
|
29
|
+
/**
|
|
30
|
+
* Window features string for `window.open`.
|
|
31
|
+
* Defaults to `'noopener,noreferrer'`; supply this only to override the
|
|
32
|
+
* default (e.g. to add `'popup'`). `noopener` and `noreferrer` are always
|
|
33
|
+
* included regardless of what is provided here.
|
|
34
|
+
*/
|
|
35
|
+
rel?: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'app';
|
|
38
|
+
/** The target app's `appId`. */
|
|
39
|
+
appId: string;
|
|
40
|
+
target?: LinkOpenTarget;
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'deep';
|
|
43
|
+
/** The target app's `appId`. */
|
|
44
|
+
appId: string;
|
|
45
|
+
/**
|
|
46
|
+
* Identifies the deep-linkable page within the target app.
|
|
47
|
+
* Corresponds to `DeepLinkEntry.path` (or `path?params`) as declared in
|
|
48
|
+
* `app.manifest.json#linkable` or `appConfig.linkable`.
|
|
49
|
+
*/
|
|
50
|
+
deepLinkId: string;
|
|
51
|
+
/** App-specific query params. Platform context params are injected automatically. */
|
|
52
|
+
params?: Record<string, string>;
|
|
53
|
+
target?: LinkOpenTarget;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Platform context fields passed to `resolveLink` to enable automatic link-click
|
|
57
|
+
* tracking via `SL.analytics.browser.trackLinkClick`.
|
|
58
|
+
*
|
|
59
|
+
* `collectionId` is required; all other `CollectionAnalyticsEvent` fields are
|
|
60
|
+
* optional and will be merged into the fired event. The resolver derives
|
|
61
|
+
* `isExternal`, `destinationAppId`, `linkTitle`, and `href` automatically from
|
|
62
|
+
* the `LinkTarget` — supply any of those here only if you need to override them.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* SL.navigation.resolveLink(link, {
|
|
66
|
+
* track: { collectionId, productId },
|
|
67
|
+
* });
|
|
68
|
+
*/
|
|
69
|
+
export type LinkTrackingContext = {
|
|
70
|
+
collectionId: string;
|
|
71
|
+
} & Partial<Omit<CollectionAnalyticsEvent, 'collectionId' | 'eventType'>>;
|
|
72
|
+
/**
|
|
73
|
+
* Context passed to `SL.navigation.resolveLink` to control execution environment.
|
|
74
|
+
* All fields are optional — the resolver detects sensible defaults at runtime.
|
|
75
|
+
*/
|
|
76
|
+
export interface ResolveLinkContext {
|
|
77
|
+
/**
|
|
78
|
+
* True when running inside a SmartLinks container, widget, or iframe.
|
|
79
|
+
* Defaults to auto-detection via `window.parent !== window`.
|
|
80
|
+
*/
|
|
81
|
+
embedded?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Override for the `postMessage` target window.
|
|
84
|
+
* Defaults to `window.parent`. Useful in tests and hosts that proxy messages.
|
|
85
|
+
*/
|
|
86
|
+
postTarget?: Window | null;
|
|
87
|
+
/**
|
|
88
|
+
* Override for the navigation window.
|
|
89
|
+
* Defaults to `window`. Useful in tests.
|
|
90
|
+
*/
|
|
91
|
+
win?: Window;
|
|
92
|
+
/**
|
|
93
|
+
* When provided, `resolveLink` automatically fires a `click_link` analytics
|
|
94
|
+
* event via `SL.analytics.browser.trackLinkClick` immediately before
|
|
95
|
+
* navigating. Supply at minimum `collectionId`; add `productId`, `proofId`,
|
|
96
|
+
* or any other `CollectionAnalyticsEvent` fields you want on the event.
|
|
97
|
+
*
|
|
98
|
+
* The resolver derives `isExternal`, `destinationAppId`, `linkTitle`, and
|
|
99
|
+
* `href` from the `LinkTarget` automatically. Fields you supply here take
|
|
100
|
+
* precedence over the derived values if there is a conflict.
|
|
101
|
+
*
|
|
102
|
+
* Called synchronously so the event fires even for external `_blank` links
|
|
103
|
+
* that unload the page immediately after.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* SL.navigation.resolveLink(link, {
|
|
107
|
+
* track: { collectionId, productId },
|
|
108
|
+
* });
|
|
109
|
+
*/
|
|
110
|
+
track?: LinkTrackingContext;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The object returned by `SL.navigation.resolveLink`.
|
|
114
|
+
*
|
|
115
|
+
* - `navigate()` — performs the navigation (postMessage, location.assign, or window.open).
|
|
116
|
+
* - `describe()` — returns a plain-text summary suitable for `aria-label` and AI agents.
|
|
117
|
+
*/
|
|
118
|
+
export interface ResolvedLink {
|
|
119
|
+
navigate(): void;
|
|
120
|
+
describe(): string;
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.11.
|
|
3
|
+
Version: 1.11.13 | Generated: 2026-05-05T09:55:20.018Z
|
|
4
4
|
|
|
5
5
|
This is a concise summary of all available API functions and types.
|
|
6
6
|
|
|
@@ -110,6 +110,7 @@ The Smartlinks SDK is organized into the following namespaces:
|
|
|
110
110
|
- **jobs** - Functions for jobs operations
|
|
111
111
|
- **journeysAnalytics** - Functions for journeysAnalytics operations
|
|
112
112
|
- **location** - Functions for location operations
|
|
113
|
+
- **navigation** - Functions for navigation operations
|
|
113
114
|
- **order** - Functions for order operations
|
|
114
115
|
- **products** - Functions for products operations
|
|
115
116
|
- **realtime** - Functions for realtime operations
|
|
@@ -5669,6 +5670,57 @@ interface RecordLoyaltyTransactionBody {
|
|
|
5669
5670
|
|
|
5670
5671
|
**DataBlock** = `Record<string, unknown>`
|
|
5671
5672
|
|
|
5673
|
+
### navigation
|
|
5674
|
+
|
|
5675
|
+
**ResolveLinkContext** (interface)
|
|
5676
|
+
```typescript
|
|
5677
|
+
interface ResolveLinkContext {
|
|
5678
|
+
* True when running inside a SmartLinks container, widget, or iframe.
|
|
5679
|
+
* Defaults to auto-detection via `window.parent !== window`.
|
|
5680
|
+
embedded?: boolean;
|
|
5681
|
+
* Override for the `postMessage` target window.
|
|
5682
|
+
* Defaults to `window.parent`. Useful in tests and hosts that proxy messages.
|
|
5683
|
+
postTarget?: Window | null;
|
|
5684
|
+
* Override for the navigation window.
|
|
5685
|
+
* Defaults to `window`. Useful in tests.
|
|
5686
|
+
win?: Window;
|
|
5687
|
+
* When provided, `resolveLink` automatically fires a `click_link` analytics
|
|
5688
|
+
* event via `SL.analytics.browser.trackLinkClick` immediately before
|
|
5689
|
+
* navigating. Supply at minimum `collectionId`; add `productId`, `proofId`,
|
|
5690
|
+
* or any other `CollectionAnalyticsEvent` fields you want on the event.
|
|
5691
|
+
*
|
|
5692
|
+
* The resolver derives `isExternal`, `destinationAppId`, `linkTitle`, and
|
|
5693
|
+
* `href` from the `LinkTarget` automatically. Fields you supply here take
|
|
5694
|
+
* precedence over the derived values if there is a conflict.
|
|
5695
|
+
*
|
|
5696
|
+
* Called synchronously so the event fires even for external `_blank` links
|
|
5697
|
+
* that unload the page immediately after.
|
|
5698
|
+
*
|
|
5699
|
+
* @example
|
|
5700
|
+
* SL.navigation.resolveLink(link, {
|
|
5701
|
+
* track: { collectionId, productId },
|
|
5702
|
+
* });
|
|
5703
|
+
track?: LinkTrackingContext;
|
|
5704
|
+
}
|
|
5705
|
+
```
|
|
5706
|
+
|
|
5707
|
+
**ResolvedLink** (interface)
|
|
5708
|
+
```typescript
|
|
5709
|
+
interface ResolvedLink {
|
|
5710
|
+
navigate(): void;
|
|
5711
|
+
describe(): string;
|
|
5712
|
+
}
|
|
5713
|
+
```
|
|
5714
|
+
|
|
5715
|
+
**LinkOpenTarget** = `'_self' | '_blank'`
|
|
5716
|
+
|
|
5717
|
+
**LinkTarget** = ``
|
|
5718
|
+
|
|
5719
|
+
**LinkTrackingContext** (type)
|
|
5720
|
+
```typescript
|
|
5721
|
+
type LinkTrackingContext = { collectionId: string }
|
|
5722
|
+
```
|
|
5723
|
+
|
|
5672
5724
|
### nfc
|
|
5673
5725
|
|
|
5674
5726
|
**NfcTagInfo** (interface)
|
|
@@ -8560,6 +8612,11 @@ List available AI models
|
|
|
8560
8612
|
**get**(collectionId: string, modelId: string) → `Promise<AIModel>`
|
|
8561
8613
|
Get specific model information
|
|
8562
8614
|
|
|
8615
|
+
### navigation
|
|
8616
|
+
|
|
8617
|
+
**resolveLink**(link: LinkTarget, ctx: ResolveLinkContext = {}) → `ResolvedLink`
|
|
8618
|
+
Resolve a stored `LinkTarget` into an executable navigation action. The resolver handles the embedded/standalone distinction automatically: | Kind | Embedded (container/widget/iframe) | Standalone | |-------------------|---------------------------------------------------------------|-------------------| | `external _blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same | | `external _self` | `window.location.assign(url)` | same | | `app` / `deep` | `postMessage` to parent shell; shell appends context params | hash-route assign | Context params (`collectionId`, `productId`, `proofId`, etc.) are **not** included in the message payload — the parent SmartLinks shell owns them and appends them before broadcasting the navigation event. const r = SL.navigation.resolveLink(config.ctaLink); r.navigate(); // perform navigation button.ariaLabel = r.describe(); // human-readable label
|
|
8619
|
+
|
|
8563
8620
|
### nfc
|
|
8564
8621
|
|
|
8565
8622
|
**claimTag**(data: NfcClaimTagRequest) → `Promise<NfcTagInfo>`
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Complete guide to registering and discovering navigable states in SmartLinks apps, enabling portal menus, AI orchestrators, and other apps to deep-link directly into specific views.
|
|
4
4
|
|
|
5
|
+
This doc covers both sides of the contract: **suppliers** declare their navigable states in the manifest and app config; **consumers** discover those states at runtime, present them in a `LinkPicker` (admin), and resolve them with `SL.navigation.resolveLink` (public widgets and executors). External URLs and in-platform deep links are both first-class `LinkTarget` variants — a single type persisted, a single resolver called at the point of navigation.
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Table of Contents
|
|
@@ -19,8 +21,12 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
19
21
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
20
22
|
- [AI Orchestration](#ai-orchestration)
|
|
21
23
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
24
|
+
- [Link Picker — Admin Configuration](#link-picker--admin-configuration)
|
|
25
|
+
- [Rendering Links at Runtime](#rendering-links-at-runtime)
|
|
22
26
|
- [TypeScript Types](#typescript-types)
|
|
23
|
-
- [
|
|
27
|
+
- [Supplier Responsibilities](#supplier-responsibilities)
|
|
28
|
+
- [Consumer Responsibilities](#consumer-responsibilities)
|
|
29
|
+
- [Anti-Patterns](#anti-patterns)
|
|
24
30
|
|
|
25
31
|
---
|
|
26
32
|
|
|
@@ -72,6 +78,34 @@ Consumers merge both sources to get the full set of navigable states for an app.
|
|
|
72
78
|
|
|
73
79
|
---
|
|
74
80
|
|
|
81
|
+
## Two Roles: Suppliers and Consumers
|
|
82
|
+
|
|
83
|
+
Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
|
|
84
|
+
|
|
85
|
+
| Role | What you do | Primary surface |
|
|
86
|
+
|------|-------------|-----------------|
|
|
87
|
+
| **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
|
|
88
|
+
| **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
|
|
89
|
+
|
|
90
|
+
Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
|
|
91
|
+
|
|
92
|
+
### Supplier checklist
|
|
93
|
+
|
|
94
|
+
- [ ] Static navigable views declared in `app.manifest.json#linkable`
|
|
95
|
+
- [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
|
|
96
|
+
- [ ] Every entry has a concise, human-readable `title`
|
|
97
|
+
|
|
98
|
+
### Consumer checklist
|
|
99
|
+
|
|
100
|
+
- [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
|
|
101
|
+
- [ ] Merges `manifest.linkable` + `config.linkable` to present navigable states in the picker
|
|
102
|
+
- [ ] Persists the admin's choice as a `LinkTarget` discriminated union — never a raw URL string
|
|
103
|
+
- [ ] Renders / navigates using `SL.navigation.resolveLink(link)` — never `window.open` or `<a href>` directly
|
|
104
|
+
- [ ] Uses `<LinkPicker />` from `@proveanything/smartlinks-utils-ui` in admin — never a free-text field for app IDs or URLs
|
|
105
|
+
- [ ] External URLs are configured through `LinkPicker` too (kind: `'external'`), not a separate field
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
75
109
|
## Two Sources of Truth
|
|
76
110
|
|
|
77
111
|
### Static Links — App Manifest
|
|
@@ -462,6 +496,143 @@ if (entry) {
|
|
|
462
496
|
}
|
|
463
497
|
```
|
|
464
498
|
|
|
499
|
+
`onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
### Link Picker — Admin Configuration
|
|
504
|
+
|
|
505
|
+
When an admin needs to configure any outbound link — to another installed app, a specific page within an app, or an external URL — **never use a bare text input**. Use `<LinkPicker />` from `@proveanything/smartlinks-utils-ui`. It handles all three `LinkTarget` kinds and produces a single `LinkTarget` value to persist.
|
|
506
|
+
|
|
507
|
+
```tsx
|
|
508
|
+
import { LinkPicker, type LinkTarget } from '@proveanything/smartlinks-utils-ui';
|
|
509
|
+
|
|
510
|
+
<LinkPicker
|
|
511
|
+
collectionId={collectionId}
|
|
512
|
+
currentAppId={appId} // include self in the app list
|
|
513
|
+
value={config.ctaLink} // LinkTarget | null
|
|
514
|
+
onChange={(next) => setConfig({ ...config, ctaLink: next })}
|
|
515
|
+
label="Button destination"
|
|
516
|
+
helpText="Choose an installed app, a specific page, or an external URL."
|
|
517
|
+
/>
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
`LinkPicker` internally calls `SL.collection.getAppsConfig`, merges `manifest.linkable` + `config.linkable` for whichever app is selected, and lets the admin pick the link kind (external / app / deep link). You never need to build this UI yourself.
|
|
521
|
+
|
|
522
|
+
#### What `LinkPicker` produces
|
|
523
|
+
|
|
524
|
+
| Admin selects | `LinkTarget` stored |
|
|
525
|
+
|---------------|---------------------|
|
|
526
|
+
| External URL (opens in new tab) | `{ kind: 'external', url: '…', target: '_blank', rel: 'noopener noreferrer' }` |
|
|
527
|
+
| External URL (same tab) | `{ kind: 'external', url: '…', target: '_self' }` |
|
|
528
|
+
| An installed app, no specific page | `{ kind: 'app', appId: '…' }` |
|
|
529
|
+
| An installed app + specific page | `{ kind: 'deep', appId: '…', deepLinkId: '…', params: {…} }` |
|
|
530
|
+
|
|
531
|
+
#### How app/deep links are discovered inside the picker
|
|
532
|
+
|
|
533
|
+
The picker calls `SL.collection.getAppsConfig` and for the chosen app merges:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
const entries: DeepLinkEntry[] = [
|
|
537
|
+
...(app.manifest?.linkable ?? []),
|
|
538
|
+
...(app.config?.linkable ?? []),
|
|
539
|
+
];
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
If an app has no declared entries, the picker offers the `app` kind ("Open app — default route") so the admin can still configure a link to it.
|
|
543
|
+
|
|
544
|
+
> **`kind: 'app'` and discoverability:** An app linked via `kind: 'app'` opens to its default route. If you want that default landing to have a meaningful title in the picker and in AI orchestration, declare a `linkable` entry for `"/"` in your manifest — it will appear as an explicit option rather than the fallback.
|
|
545
|
+
|
|
546
|
+
#### Persisting the value
|
|
547
|
+
|
|
548
|
+
`LinkTarget` is the persistence type. Store it as-is in your app config — never convert to a URL string before saving.
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
// ✅ Correct
|
|
552
|
+
await SL.appConfiguration.setConfig({
|
|
553
|
+
collectionId, appId,
|
|
554
|
+
config: { ...config, ctaLink: linkTarget },
|
|
555
|
+
admin: true,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ❌ Wrong — you've thrown away the kind, params, and context-injection
|
|
559
|
+
await SL.appConfiguration.setConfig({
|
|
560
|
+
collectionId, appId,
|
|
561
|
+
config: { ...config, ctaUrl: resolvedUrl },
|
|
562
|
+
admin: true,
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
### Rendering Links at Runtime
|
|
569
|
+
|
|
570
|
+
In public widgets, executors, and any non-admin context, resolve a stored `LinkTarget` with `SL.navigation.resolveLink`. This is pure SDK — no React, no admin package dependency.
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
import * as SL from '@proveanything/smartlinks';
|
|
574
|
+
import type { LinkTarget } from '@proveanything/smartlinks';
|
|
575
|
+
|
|
576
|
+
const resolved = SL.navigation.resolveLink(link);
|
|
577
|
+
|
|
578
|
+
resolved.navigate(); // executes the navigation (postMessage, location.assign, or window.open)
|
|
579
|
+
resolved.describe(); // plain-text label for aria-label, logging, AI agent descriptions
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`resolveLink` handles the embedded/standalone distinction automatically:
|
|
583
|
+
|
|
584
|
+
| `LinkTarget.kind` | Inside container / iframe | Standalone (direct URL) |
|
|
585
|
+
|-------------------|--------------------------|-------------------------|
|
|
586
|
+
| `external` + `_blank` | `window.open(url, '_blank', 'noopener,noreferrer')` | same |
|
|
587
|
+
| `external` + `_self` | `window.location.assign(url)` | same |
|
|
588
|
+
| `app` / `deep` | `postMessage` to parent shell — shell appends `collectionId`, `productId`, `proofId`, etc. | constructs hash route locally and assigns/opens |
|
|
589
|
+
|
|
590
|
+
You never need to branch on `embedded` yourself — `resolveLink` reads the execution context.
|
|
591
|
+
|
|
592
|
+
#### React component pattern
|
|
593
|
+
|
|
594
|
+
```tsx
|
|
595
|
+
import * as SL from '@proveanything/smartlinks';
|
|
596
|
+
import type { LinkTarget } from '@proveanything/smartlinks';
|
|
597
|
+
|
|
598
|
+
function CTAButton({ link, label }: { link: LinkTarget; label: string }) {
|
|
599
|
+
const resolved = SL.navigation.resolveLink(link);
|
|
600
|
+
return (
|
|
601
|
+
<button type="button" onClick={() => resolved.navigate()} aria-label={resolved.describe()}>
|
|
602
|
+
{label}
|
|
603
|
+
</button>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
#### Worked example — Raffle "See the prizes" button (full round-trip)
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
// --- Admin app (uses LinkPicker from smartlinks-utils-ui) ---
|
|
612
|
+
import { LinkPicker, type LinkTarget } from '@proveanything/smartlinks-utils-ui';
|
|
613
|
+
|
|
614
|
+
<LinkPicker
|
|
615
|
+
collectionId={collectionId}
|
|
616
|
+
currentAppId={appId}
|
|
617
|
+
value={config.prizesLink}
|
|
618
|
+
onChange={(prizesLink) => setConfig({ ...config, prizesLink })}
|
|
619
|
+
label="Prizes page"
|
|
620
|
+
helpText="Link to a content app describing this raffle's prizes."
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
// --- Public widget (uses SL.navigation.resolveLink from @proveanything/smartlinks) ---
|
|
624
|
+
import * as SL from '@proveanything/smartlinks';
|
|
625
|
+
|
|
626
|
+
function PrizesButton({ link }: { link: LinkTarget }) {
|
|
627
|
+
const r = SL.navigation.resolveLink(link);
|
|
628
|
+
return (
|
|
629
|
+
<button type="button" onClick={() => r.navigate()} aria-label={r.describe()}>
|
|
630
|
+
See the prizes
|
|
631
|
+
</button>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
465
636
|
---
|
|
466
637
|
|
|
467
638
|
## TypeScript Types
|
|
@@ -489,28 +660,80 @@ export interface DeepLinkEntry {
|
|
|
489
660
|
|
|
490
661
|
/** Convenience alias for an array of DeepLinkEntry */
|
|
491
662
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
663
|
+
|
|
664
|
+
/** Where the link opens once resolved */
|
|
665
|
+
export type LinkOpenTarget = '_self' | '_blank';
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Discriminated union representing any link a `LinkPicker` can produce.
|
|
669
|
+
* This is the persistence type — store it as-is in app config; never convert to a URL.
|
|
670
|
+
*
|
|
671
|
+
* Resolved at render time with `SL.navigation.resolveLink(link)`.
|
|
672
|
+
*/
|
|
673
|
+
export type LinkTarget =
|
|
674
|
+
/** A URL outside the SmartLinks platform */
|
|
675
|
+
| { kind: 'external'; url: string; target: LinkOpenTarget; rel?: string }
|
|
676
|
+
/** Open an installed app at its default route */
|
|
677
|
+
| { kind: 'app'; appId: string; target?: LinkOpenTarget }
|
|
678
|
+
/** Open a specific deep-linkable page within an installed app */
|
|
679
|
+
| { kind: 'deep'; appId: string; deepLinkId: string;
|
|
680
|
+
params?: Record<string, string>; target?: LinkOpenTarget };
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The object returned by `SL.navigation.resolveLink`.
|
|
684
|
+
* `navigate()` performs the navigation; `describe()` returns a plain-text label
|
|
685
|
+
* suitable for aria-label attributes and AI agent descriptions.
|
|
686
|
+
*/
|
|
687
|
+
export interface ResolvedLink {
|
|
688
|
+
navigate(): void;
|
|
689
|
+
describe(): string;
|
|
690
|
+
}
|
|
492
691
|
```
|
|
493
692
|
|
|
494
693
|
---
|
|
495
694
|
|
|
496
|
-
##
|
|
695
|
+
## Supplier Responsibilities
|
|
497
696
|
|
|
498
|
-
|
|
697
|
+
Rules for apps that **expose** navigable states to the platform:
|
|
499
698
|
|
|
500
699
|
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
|
|
501
700
|
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
502
701
|
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
503
702
|
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
504
703
|
5. **`title` is required** — every entry must have a human-readable label.
|
|
505
|
-
6. **
|
|
506
|
-
7. **
|
|
507
|
-
8. **
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
704
|
+
6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
705
|
+
7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
706
|
+
8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## Consumer Responsibilities
|
|
711
|
+
|
|
712
|
+
Rules for apps that **navigate to** states in other installed apps or external URLs:
|
|
713
|
+
|
|
714
|
+
1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
|
|
715
|
+
2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
|
|
716
|
+
3. **Persist as `LinkTarget`** — store the discriminated union `{ kind, … }` as-is in your app config; never convert to a raw URL string before saving.
|
|
717
|
+
4. **Navigate with `SL.navigation.resolveLink`** — call `resolveLink(link).navigate()` for all outbound navigation; never call `window.open`, `window.location.assign`, or `<a href>` directly from app logic.
|
|
718
|
+
5. **Use `<LinkPicker />` in admin** — admin forms that configure any outbound link (internal or external) must use `LinkPicker` from `@proveanything/smartlinks-utils-ui`, never a free-text field.
|
|
719
|
+
6. **External URLs belong in `LinkPicker` too** — `kind: 'external'` is a first-class option, not a separate field. One picker, one stored shape, one resolver.
|
|
720
|
+
7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
721
|
+
8. **Show fallback for apps with no declared entries** — `LinkPicker` uses `kind: 'app'` (default route) so admins can still link to an app that hasn't declared any deep-linkable pages.
|
|
722
|
+
|
|
723
|
+
---
|
|
724
|
+
|
|
725
|
+
## Anti-Patterns
|
|
726
|
+
|
|
727
|
+
| ❌ Anti-pattern | ✅ Correct approach |
|
|
728
|
+
|----------------|---------------------|
|
|
729
|
+
| Free-text "Target app ID" field in admin UI | `<LinkPicker />` from `smartlinks-utils-ui` |
|
|
730
|
+
| Separate "URL" field next to internal link field | `LinkPicker` handles both; `kind: 'external'` is a first-class option |
|
|
731
|
+
| Hard-coded `https://…` links between apps in the same collection | `kind: 'deep'` / `kind: 'app'` resolved with `SL.navigation.resolveLink` |
|
|
732
|
+
| `window.open()` / `<a target="_blank">` called directly from app logic | `SL.navigation.resolveLink(link).navigate()` |
|
|
733
|
+
| Converting a `LinkTarget` to a URL string before saving | Persist the `LinkTarget` union; resolve at navigation time |
|
|
734
|
+
| Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
|
|
735
|
+
| Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
|
|
736
|
+
| Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
|
|
737
|
+
| Importing `LinkPicker` in a public widget or executor | `LinkPicker` is admin-only; use `SL.navigation.resolveLink` in public code |
|
|
738
|
+
| Re-implementing picker logic per app | Use `<LinkPicker />` from `@proveanything/smartlinks-utils-ui` |
|
|
739
|
+
|
package/openapi.yaml
CHANGED
|
@@ -21044,6 +21044,28 @@ components:
|
|
|
21044
21044
|
type: string
|
|
21045
21045
|
required:
|
|
21046
21046
|
- points
|
|
21047
|
+
ResolveLinkContext:
|
|
21048
|
+
type: object
|
|
21049
|
+
properties:
|
|
21050
|
+
embedded:
|
|
21051
|
+
type: boolean
|
|
21052
|
+
postTarget:
|
|
21053
|
+
$ref: "#/components/schemas/Window"
|
|
21054
|
+
win:
|
|
21055
|
+
$ref: "#/components/schemas/Window"
|
|
21056
|
+
track:
|
|
21057
|
+
$ref: "#/components/schemas/LinkTrackingContext"
|
|
21058
|
+
ResolvedLink:
|
|
21059
|
+
type: object
|
|
21060
|
+
properties: {}
|
|
21061
|
+
LinkOpenTarget:
|
|
21062
|
+
type: string
|
|
21063
|
+
enum:
|
|
21064
|
+
- _self
|
|
21065
|
+
- _blank
|
|
21066
|
+
LinkTrackingContext:
|
|
21067
|
+
type: object
|
|
21068
|
+
properties: {}
|
|
21047
21069
|
NfcTagInfo:
|
|
21048
21070
|
type: object
|
|
21049
21071
|
properties:
|