@proveanything/smartlinks 1.11.12 → 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.
@@ -38,3 +38,4 @@ export { loyalty } from "./loyalty";
38
38
  export { translations } from "./translations";
39
39
  export { config } from "./config";
40
40
  export { http } from "./http";
41
+ export { navigation } from "./navigation";
package/dist/api/index.js CHANGED
@@ -41,3 +41,4 @@ export { loyalty } from "./loyalty";
41
41
  export { translations } from "./translations";
42
42
  export { config } from "./config";
43
43
  export { http } from "./http";
44
+ export { navigation } from "./navigation";
@@ -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 = {}));
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.12 | Generated: 2026-05-05T09:01:25.366Z
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,7 +21,8 @@ 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)
22
- - [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
24
+ - [Link Picker Admin Configuration](#link-picker--admin-configuration)
25
+ - [Rendering Links at Runtime](#rendering-links-at-runtime)
23
26
  - [TypeScript Types](#typescript-types)
24
27
  - [Supplier Responsibilities](#supplier-responsibilities)
25
28
  - [Consumer Responsibilities](#consumer-responsibilities)
@@ -95,10 +98,11 @@ Both roles are required for end-to-end cross-app linking to work. An app that on
95
98
  ### Consumer checklist
96
99
 
97
100
  - [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
98
- - [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
99
- - [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
100
- - [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
101
- - [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
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
102
106
 
103
107
  ---
104
108
 
@@ -496,25 +500,37 @@ if (entry) {
496
500
 
497
501
  ---
498
502
 
499
- ### Building a Cross-App Deep-Link Picker
503
+ ### Link Picker Admin Configuration
500
504
 
501
- When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App Page), and persist the result as `AppDeepLink`.
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.
502
506
 
503
- #### Why not a free-text URL?
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
+ ```
504
519
 
505
- Hard-coded URLs break when:
506
- - The target app is re-deployed on a new domain.
507
- - The user is inside a container and full-page navigation would destroy their session.
508
- - Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
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.
509
521
 
510
- #### Step 1 — Discover installed apps
522
+ #### What `LinkPicker` produces
511
523
 
512
- ```typescript
513
- const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
514
- // Returns all installed apps, each with its manifest and saved config
515
- ```
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: {…} }` |
516
530
 
517
- #### Step 2 Merge linkable entries for the chosen app
531
+ #### How app/deep links are discovered inside the picker
532
+
533
+ The picker calls `SL.collection.getAppsConfig` and for the chosen app merges:
518
534
 
519
535
  ```typescript
520
536
  const entries: DeepLinkEntry[] = [
@@ -523,94 +539,99 @@ const entries: DeepLinkEntry[] = [
523
539
  ];
524
540
  ```
525
541
 
526
- If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
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
527
547
 
528
- #### Step 3 Persist as `AppDeepLink`, never as a URL
548
+ `LinkTarget` is the persistence type. Store it as-is in your app config — never convert to a URL string before saving.
529
549
 
530
550
  ```typescript
531
- // Correct — portable, context-preserving
532
- const link: AppDeepLink = {
533
- appId: app.appId,
534
- path: entry.path ?? '/',
535
- params: entry.params,
536
- label: entry.title, // cache for display; re-resolve at render time
537
- };
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
+ });
538
564
  ```
539
565
 
540
- The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
566
+ ---
567
+
568
+ ### Rendering Links at Runtime
541
569
 
542
- #### Step 4Navigate at runtime
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.
543
571
 
544
572
  ```typescript
545
- const { onNavigate } = useAppContext();
573
+ import * as SL from '@proveanything/smartlinks';
574
+ import type { LinkTarget } from '@proveanything/smartlinks';
546
575
 
547
- onNavigate({
548
- appId: link.appId,
549
- deepLink: link.path,
550
- params: link.params,
551
- });
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
552
580
  ```
553
581
 
554
- #### Worked example Raffle "See the prizes" button
582
+ `resolveLink` handles the embedded/standalone distinction automatically:
555
583
 
556
- ```tsx
557
- // --- Admin side ---
558
- function PrizesLinkField({ collectionId, value, onChange }) {
559
- const [apps, setApps] = useState([]);
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.
560
591
 
561
- useEffect(() => {
562
- SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
563
- }, [collectionId]);
592
+ #### React component pattern
564
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);
565
600
  return (
566
- <>
567
- {/* App dropdown */}
568
- <select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
569
- <option value="">— Choose an app —</option>
570
- {apps.map(a => (
571
- <option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
572
- ))}
573
- </select>
574
-
575
- {/* Page dropdown for chosen app */}
576
- {value?.appId && (() => {
577
- const app = apps.find(a => a.appId === value.appId);
578
- const entries = [
579
- ...(app?.manifest?.linkable ?? []),
580
- ...(app?.config?.linkable ?? []),
581
- ];
582
- if (entries.length === 0) {
583
- return <select disabled><option>Default route only</option></select>;
584
- }
585
- return (
586
- <select onChange={e => {
587
- const entry = entries[+e.target.value];
588
- onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
589
- }}>
590
- {entries.map((entry, i) => (
591
- <option key={i} value={i}>{entry.title}</option>
592
- ))}
593
- </select>
594
- );
595
- })()}
596
- </>
601
+ <button type="button" onClick={() => resolved.navigate()} aria-label={resolved.describe()}>
602
+ {label}
603
+ </button>
597
604
  );
598
605
  }
599
-
600
- // --- Public side ---
601
- const { onNavigate } = useAppContext();
602
- {prizesLink && (
603
- <button onClick={() => onNavigate({
604
- appId: prizesLink.appId,
605
- deepLink: prizesLink.path,
606
- params: prizesLink.params,
607
- })}>
608
- See {prizesLink.label ?? 'the prizes'}
609
- </button>
610
- )}
611
606
  ```
612
607
 
613
- > **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
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
+ ```
614
635
 
615
636
  ---
616
637
 
@@ -640,25 +661,32 @@ export interface DeepLinkEntry {
640
661
  /** Convenience alias for an array of DeepLinkEntry */
641
662
  export type DeepLinkRegistry = DeepLinkEntry[];
642
663
 
664
+ /** Where the link opens once resolved */
665
+ export type LinkOpenTarget = '_self' | '_blank';
666
+
643
667
  /**
644
- * A cross-app deep link persisted by a consumer app's config.
645
- * Never store a raw URL — store this instead.
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.
646
670
  *
647
- * At render time, re-resolve via SL.collection.getAppsConfig to detect
648
- * renamed or removed pages.
671
+ * Resolved at render time with `SL.navigation.resolveLink(link)`.
649
672
  */
650
- export interface AppDeepLink {
651
- /** The target app's appId */
652
- appId: string;
653
- /** Hash route path within the target app (e.g. "/prizes/2026") */
654
- path: string;
655
- /** App-specific query params (platform context is injected automatically) */
656
- params?: Record<string, string>;
657
- /**
658
- * Cached display label from the time of selection.
659
- * For display only — do not use for navigation decisions.
660
- */
661
- label?: string;
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;
662
690
  }
663
691
  ```
664
692
 
@@ -681,16 +709,16 @@ Rules for apps that **expose** navigable states to the platform:
681
709
 
682
710
  ## Consumer Responsibilities
683
711
 
684
- Rules for apps that **navigate to** states in other installed apps:
712
+ Rules for apps that **navigate to** states in other installed apps or external URLs:
685
713
 
686
714
  1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
687
715
  2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
688
- 3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
689
- 4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
690
- 5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
691
- 6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
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.
692
720
  7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
693
- 8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
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.
694
722
 
695
723
  ---
696
724
 
@@ -698,13 +726,14 @@ Rules for apps that **navigate to** states in other installed apps:
698
726
 
699
727
  | ❌ Anti-pattern | ✅ Correct approach |
700
728
  |----------------|---------------------|
701
- | Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
702
- | Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
703
- | Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
704
- | `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
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 |
705
734
  | Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
706
735
  | Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
707
736
  | Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
708
- | Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
709
- | Re-implementing picker logic per app | Share a canonical picker component across your app suite |
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` |
710
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:
@@ -25,6 +25,7 @@ export * from "./location";
25
25
  export * from "./jobs";
26
26
  export * from "./realtime";
27
27
  export * from "./tags";
28
+ export * from "./navigation";
28
29
  export * from "./order";
29
30
  export * from "./crate";
30
31
  export * from "./iframeResponder";
@@ -27,6 +27,7 @@ export * from "./location";
27
27
  export * from "./jobs";
28
28
  export * from "./realtime";
29
29
  export * from "./tags";
30
+ export * from "./navigation";
30
31
  export * from "./order";
31
32
  export * from "./crate";
32
33
  export * from "./iframeResponder";
@@ -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 {};
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.12 | Generated: 2026-05-05T09:01:25.366Z
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,7 +21,8 @@ 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)
22
- - [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
24
+ - [Link Picker Admin Configuration](#link-picker--admin-configuration)
25
+ - [Rendering Links at Runtime](#rendering-links-at-runtime)
23
26
  - [TypeScript Types](#typescript-types)
24
27
  - [Supplier Responsibilities](#supplier-responsibilities)
25
28
  - [Consumer Responsibilities](#consumer-responsibilities)
@@ -95,10 +98,11 @@ Both roles are required for end-to-end cross-app linking to work. An app that on
95
98
  ### Consumer checklist
96
99
 
97
100
  - [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
98
- - [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
99
- - [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
100
- - [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
101
- - [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
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
102
106
 
103
107
  ---
104
108
 
@@ -496,25 +500,37 @@ if (entry) {
496
500
 
497
501
  ---
498
502
 
499
- ### Building a Cross-App Deep-Link Picker
503
+ ### Link Picker Admin Configuration
500
504
 
501
- When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App Page), and persist the result as `AppDeepLink`.
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.
502
506
 
503
- #### Why not a free-text URL?
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
+ ```
504
519
 
505
- Hard-coded URLs break when:
506
- - The target app is re-deployed on a new domain.
507
- - The user is inside a container and full-page navigation would destroy their session.
508
- - Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
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.
509
521
 
510
- #### Step 1 — Discover installed apps
522
+ #### What `LinkPicker` produces
511
523
 
512
- ```typescript
513
- const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
514
- // Returns all installed apps, each with its manifest and saved config
515
- ```
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: {…} }` |
516
530
 
517
- #### Step 2 Merge linkable entries for the chosen app
531
+ #### How app/deep links are discovered inside the picker
532
+
533
+ The picker calls `SL.collection.getAppsConfig` and for the chosen app merges:
518
534
 
519
535
  ```typescript
520
536
  const entries: DeepLinkEntry[] = [
@@ -523,94 +539,99 @@ const entries: DeepLinkEntry[] = [
523
539
  ];
524
540
  ```
525
541
 
526
- If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
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
527
547
 
528
- #### Step 3 Persist as `AppDeepLink`, never as a URL
548
+ `LinkTarget` is the persistence type. Store it as-is in your app config — never convert to a URL string before saving.
529
549
 
530
550
  ```typescript
531
- // Correct — portable, context-preserving
532
- const link: AppDeepLink = {
533
- appId: app.appId,
534
- path: entry.path ?? '/',
535
- params: entry.params,
536
- label: entry.title, // cache for display; re-resolve at render time
537
- };
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
+ });
538
564
  ```
539
565
 
540
- The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
566
+ ---
567
+
568
+ ### Rendering Links at Runtime
541
569
 
542
- #### Step 4Navigate at runtime
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.
543
571
 
544
572
  ```typescript
545
- const { onNavigate } = useAppContext();
573
+ import * as SL from '@proveanything/smartlinks';
574
+ import type { LinkTarget } from '@proveanything/smartlinks';
546
575
 
547
- onNavigate({
548
- appId: link.appId,
549
- deepLink: link.path,
550
- params: link.params,
551
- });
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
552
580
  ```
553
581
 
554
- #### Worked example Raffle "See the prizes" button
582
+ `resolveLink` handles the embedded/standalone distinction automatically:
555
583
 
556
- ```tsx
557
- // --- Admin side ---
558
- function PrizesLinkField({ collectionId, value, onChange }) {
559
- const [apps, setApps] = useState([]);
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.
560
591
 
561
- useEffect(() => {
562
- SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
563
- }, [collectionId]);
592
+ #### React component pattern
564
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);
565
600
  return (
566
- <>
567
- {/* App dropdown */}
568
- <select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
569
- <option value="">— Choose an app —</option>
570
- {apps.map(a => (
571
- <option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
572
- ))}
573
- </select>
574
-
575
- {/* Page dropdown for chosen app */}
576
- {value?.appId && (() => {
577
- const app = apps.find(a => a.appId === value.appId);
578
- const entries = [
579
- ...(app?.manifest?.linkable ?? []),
580
- ...(app?.config?.linkable ?? []),
581
- ];
582
- if (entries.length === 0) {
583
- return <select disabled><option>Default route only</option></select>;
584
- }
585
- return (
586
- <select onChange={e => {
587
- const entry = entries[+e.target.value];
588
- onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
589
- }}>
590
- {entries.map((entry, i) => (
591
- <option key={i} value={i}>{entry.title}</option>
592
- ))}
593
- </select>
594
- );
595
- })()}
596
- </>
601
+ <button type="button" onClick={() => resolved.navigate()} aria-label={resolved.describe()}>
602
+ {label}
603
+ </button>
597
604
  );
598
605
  }
599
-
600
- // --- Public side ---
601
- const { onNavigate } = useAppContext();
602
- {prizesLink && (
603
- <button onClick={() => onNavigate({
604
- appId: prizesLink.appId,
605
- deepLink: prizesLink.path,
606
- params: prizesLink.params,
607
- })}>
608
- See {prizesLink.label ?? 'the prizes'}
609
- </button>
610
- )}
611
606
  ```
612
607
 
613
- > **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
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
+ ```
614
635
 
615
636
  ---
616
637
 
@@ -640,25 +661,32 @@ export interface DeepLinkEntry {
640
661
  /** Convenience alias for an array of DeepLinkEntry */
641
662
  export type DeepLinkRegistry = DeepLinkEntry[];
642
663
 
664
+ /** Where the link opens once resolved */
665
+ export type LinkOpenTarget = '_self' | '_blank';
666
+
643
667
  /**
644
- * A cross-app deep link persisted by a consumer app's config.
645
- * Never store a raw URL — store this instead.
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.
646
670
  *
647
- * At render time, re-resolve via SL.collection.getAppsConfig to detect
648
- * renamed or removed pages.
671
+ * Resolved at render time with `SL.navigation.resolveLink(link)`.
649
672
  */
650
- export interface AppDeepLink {
651
- /** The target app's appId */
652
- appId: string;
653
- /** Hash route path within the target app (e.g. "/prizes/2026") */
654
- path: string;
655
- /** App-specific query params (platform context is injected automatically) */
656
- params?: Record<string, string>;
657
- /**
658
- * Cached display label from the time of selection.
659
- * For display only — do not use for navigation decisions.
660
- */
661
- label?: string;
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;
662
690
  }
663
691
  ```
664
692
 
@@ -681,16 +709,16 @@ Rules for apps that **expose** navigable states to the platform:
681
709
 
682
710
  ## Consumer Responsibilities
683
711
 
684
- Rules for apps that **navigate to** states in other installed apps:
712
+ Rules for apps that **navigate to** states in other installed apps or external URLs:
685
713
 
686
714
  1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
687
715
  2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
688
- 3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
689
- 4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
690
- 5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
691
- 6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
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.
692
720
  7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
693
- 8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
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.
694
722
 
695
723
  ---
696
724
 
@@ -698,13 +726,14 @@ Rules for apps that **navigate to** states in other installed apps:
698
726
 
699
727
  | ❌ Anti-pattern | ✅ Correct approach |
700
728
  |----------------|---------------------|
701
- | Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
702
- | Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
703
- | Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
704
- | `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
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 |
705
734
  | Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
706
735
  | Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
707
736
  | Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
708
- | Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
709
- | Re-implementing picker logic per app | Share a canonical picker component across your app suite |
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` |
710
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.11.12",
3
+ "version": "1.11.13",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",