@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.
@@ -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.11 | Generated: 2026-05-04T14:03:01.290Z
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
- - [Rules & Best Practices](#rules--best-practices)
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
- ## Rules & Best Practices
695
+ ## Supplier Responsibilities
497
696
 
498
- ### Rules
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. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
506
- 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
507
- 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
508
-
509
- ### Best Practices
510
-
511
- - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
512
- - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
513
- - **Sync dynamic links eagerly** trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
514
- - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
515
- - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
516
- - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
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:
@@ -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.11 | Generated: 2026-05-04T14:03:01.290Z
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
- - [Rules & Best Practices](#rules--best-practices)
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
- ## Rules & Best Practices
695
+ ## Supplier Responsibilities
497
696
 
498
- ### Rules
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. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
506
- 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
507
- 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
508
-
509
- ### Best Practices
510
-
511
- - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
512
- - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
513
- - **Sync dynamic links eagerly** trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
514
- - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
515
- - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
516
- - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.11.11",
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",