@shopify/shop-minis-react 0.14.0 → 0.15.0

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.
@@ -0,0 +1,43 @@
1
+ import { useMemo as f } from "react";
2
+ import { useDeeplink as p } from "../navigation/useDeeplink.js";
3
+ function q() {
4
+ const { queryParams: a } = p();
5
+ return f(() => {
6
+ const c = a?.intentQuery;
7
+ if (!c) return { query: null, data: null };
8
+ let e;
9
+ try {
10
+ e = decodeURIComponent(c);
11
+ } catch {
12
+ e = c;
13
+ }
14
+ const u = e.indexOf(":");
15
+ if (u === -1) return { query: null, data: null };
16
+ const y = e.slice(0, u), r = e.slice(u + 1), l = r.indexOf(",");
17
+ let o = l === -1 ? r : r.slice(0, l), s = l === -1 ? null : r.slice(l + 1) || null;
18
+ const n = o.match(/^gid:\/\/shopify\/(\w+)(?:\/(.+))?$/);
19
+ if (n && (o = `shopify/${n[1]}`, s = n[2] ? `gid://shopify/${n[1]}/${n[2]}` : null), !o) return { query: null, data: null };
20
+ let d = null;
21
+ const i = a?.intentData;
22
+ if (i) {
23
+ let t;
24
+ try {
25
+ t = JSON.parse(i);
26
+ } catch {
27
+ try {
28
+ t = JSON.parse(decodeURIComponent(i));
29
+ } catch {
30
+ }
31
+ }
32
+ t !== null && typeof t == "object" && !Array.isArray(t) && (d = t);
33
+ }
34
+ return {
35
+ query: { action: y, type: o, value: s },
36
+ data: d
37
+ };
38
+ }, [a]);
39
+ }
40
+ export {
41
+ q as useIntent
42
+ };
43
+ //# sourceMappingURL=useIntent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useIntent.js","sources":["../../../src/hooks/intents/useIntent.ts"],"sourcesContent":["import {useMemo} from 'react'\n\nimport {useDeeplink} from '../navigation/useDeeplink'\n\n/**\n * Structured description of an intent, following the Shopify Intents API\n * URI format: `action:type,value`\n *\n * @see https://shopify.dev/docs/api/admin-extensions/latest/target-apis/utility-apis/intents-api\n */\nexport interface IntentQuery {\n /** Verb describing the operation (e.g., 'try_on', 'create', 'edit') */\n action: string\n /** Resource type identifier (e.g., 'shopify/Product', 'shop/UserImage') */\n type: string\n /** Resource GID (e.g., 'gid://shopify/Product/123') if applicable */\n value: string | null\n}\n\nexport interface UseIntentReturn {\n /** Parsed intent query, or null if no intent was passed */\n query: IntentQuery | null\n /** Additional JSON data passed with the intent, or null */\n data: {[key: string]: unknown} | null\n}\n\n/**\n * Parses an intent passed to this mini via deeplink.\n *\n * Follows the Shopify Intents API URI format (`action:type,value`) with\n * an optional JSON data payload passed separately.\n *\n * @see https://shopify.dev/docs/api/admin-extensions/latest/target-apis/utility-apis/intents-api\n *\n * Deeplink format:\n * ?intentQuery=action:type,value&intentData={\"key\":\"value\"}\n *\n * Examples:\n * ?intentQuery=try_on:shopify/Product,gid://shopify/Product/123\n * ?intentQuery=create:shopify/Product\n * ?intentQuery=edit:shopify/Product,gid://shopify/Product/123&intentData={\"variantId\":\"456\"}\n *\n * Shorthand GID syntax (type inferred from GID):\n * ?intentQuery=edit:gid://shopify/Product/123\n * ?intentQuery=create:gid://shopify/Product\n *\n * Use this hook to receive intents from the host app (Host → Mini direction).\n */\nexport function useIntent(): UseIntentReturn {\n const {queryParams} = useDeeplink()\n\n return useMemo(() => {\n const raw = queryParams?.intentQuery\n\n if (!raw) return {query: null, data: null}\n\n let decoded: string\n try {\n decoded = decodeURIComponent(raw)\n } catch {\n decoded = raw\n }\n\n const colonIdx = decoded.indexOf(':')\n if (colonIdx === -1) return {query: null, data: null}\n\n const action = decoded.slice(0, colonIdx)\n const rest = decoded.slice(colonIdx + 1)\n const commaIdx = rest.indexOf(',')\n\n let type = commaIdx === -1 ? rest : rest.slice(0, commaIdx)\n let value = commaIdx === -1 ? null : rest.slice(commaIdx + 1) || null\n\n // Shorthand GID syntax: edit:gid://shopify/Product/123\n // Infer type from GID and use full GID as value\n const gidMatch = type.match(/^gid:\\/\\/shopify\\/(\\w+)(?:\\/(.+))?$/)\n if (gidMatch) {\n type = `shopify/${gidMatch[1]}`\n value = gidMatch[2] ? `gid://shopify/${gidMatch[1]}/${gidMatch[2]}` : null\n }\n\n if (!type) return {query: null, data: null}\n\n let data: {[key: string]: unknown} | null = null\n const rawData = queryParams?.intentData\n if (rawData) {\n let parsed: unknown\n try {\n parsed = JSON.parse(rawData)\n } catch {\n try {\n parsed = JSON.parse(decodeURIComponent(rawData))\n } catch {\n // malformed JSON — ignore\n }\n }\n if (\n parsed !== null &&\n typeof parsed === 'object' &&\n !Array.isArray(parsed)\n ) {\n data = parsed as {[key: string]: unknown}\n }\n }\n\n return {\n query: {action, type, value},\n data,\n }\n }, [queryParams])\n}\n"],"names":["useIntent","queryParams","useDeeplink","useMemo","raw","decoded","colonIdx","action","rest","commaIdx","type","value","gidMatch","data","rawData","parsed"],"mappings":";;AAgDO,SAASA,IAA6B;AACrC,QAAA,EAAC,aAAAC,EAAW,IAAIC,EAAY;AAElC,SAAOC,EAAQ,MAAM;AACnB,UAAMC,IAAMH,GAAa;AAEzB,QAAI,CAACG,EAAK,QAAO,EAAC,OAAO,MAAM,MAAM,KAAI;AAErC,QAAAC;AACA,QAAA;AACF,MAAAA,IAAU,mBAAmBD,CAAG;AAAA,IAAA,QAC1B;AACI,MAAAC,IAAAD;AAAA,IAAA;AAGN,UAAAE,IAAWD,EAAQ,QAAQ,GAAG;AACpC,QAAIC,MAAa,GAAI,QAAO,EAAC,OAAO,MAAM,MAAM,KAAI;AAEpD,UAAMC,IAASF,EAAQ,MAAM,GAAGC,CAAQ,GAClCE,IAAOH,EAAQ,MAAMC,IAAW,CAAC,GACjCG,IAAWD,EAAK,QAAQ,GAAG;AAEjC,QAAIE,IAAOD,MAAa,KAAKD,IAAOA,EAAK,MAAM,GAAGC,CAAQ,GACtDE,IAAQF,MAAa,KAAK,OAAOD,EAAK,MAAMC,IAAW,CAAC,KAAK;AAI3D,UAAAG,IAAWF,EAAK,MAAM,qCAAqC;AAMjE,QALIE,MACKF,IAAA,WAAWE,EAAS,CAAC,CAAC,IACrBD,IAAAC,EAAS,CAAC,IAAI,iBAAiBA,EAAS,CAAC,CAAC,IAAIA,EAAS,CAAC,CAAC,KAAK,OAGpE,CAACF,EAAM,QAAO,EAAC,OAAO,MAAM,MAAM,KAAI;AAE1C,QAAIG,IAAwC;AAC5C,UAAMC,IAAUb,GAAa;AAC7B,QAAIa,GAAS;AACP,UAAAC;AACA,UAAA;AACO,QAAAA,IAAA,KAAK,MAAMD,CAAO;AAAA,MAAA,QACrB;AACF,YAAA;AACF,UAAAC,IAAS,KAAK,MAAM,mBAAmBD,CAAO,CAAC;AAAA,QAAA,QACzC;AAAA,QAAA;AAAA,MAER;AAGA,MAAAC,MAAW,QACX,OAAOA,KAAW,YAClB,CAAC,MAAM,QAAQA,CAAM,MAEdF,IAAAE;AAAA,IACT;AAGK,WAAA;AAAA,MACL,OAAO,EAAC,QAAAR,GAAQ,MAAAG,GAAM,OAAAC,EAAK;AAAA,MAC3B,MAAAE;AAAA,IACF;AAAA,EAAA,GACC,CAACZ,CAAW,CAAC;AAClB;"}
package/dist/index.js CHANGED
@@ -87,21 +87,22 @@ import { useShare as Ht } from "./hooks/util/useShare.js";
87
87
  import { useImagePicker as zt } from "./hooks/util/useImagePicker.js";
88
88
  import { useKeyboardAvoidingView as Yt } from "./hooks/util/useKeyboardAvoidingView.js";
89
89
  import { useRequestPermissions as Kt } from "./hooks/util/useRequestPermissions.js";
90
- import { useOnMiniFocus as Zt } from "./hooks/events/useOnMiniFocus.js";
91
- import { useOnMiniBlur as Jt } from "./hooks/events/useOnMiniBlur.js";
92
- import { useOnMiniClose as $t } from "./hooks/events/useOnMiniClose.js";
93
- import { useOnAppStateChange as ea } from "./hooks/events/useOnAppStateChange.js";
94
- import { useOnNavigateBack as ta } from "./hooks/events/useOnNavigateBack.js";
95
- import { buildDeeplinkUrl as pa } from "./utils/buildDeeplinkUrl.js";
96
- import { MiniEntityNotFoundError as ma, MiniError as na, MiniNetworkError as sa, formatError as la } from "./utils/errors.js";
97
- import { extractBrandTheme as fa, formatReviewCount as xa, getFeaturedImages as ca, normalizeRating as da } from "./utils/merchant-card.js";
98
- import { parseUrl as ga } from "./utils/parseUrl.js";
99
- import { dataURLToBlob as Aa, fileToDataUri as Da, getResizedImageUrl as Pa, getThumbhashBlobURL as Ta, getThumbhashDataURL as ha } from "./utils/image.js";
100
- import { formatMoney as Ra } from "./utils/formatMoney.js";
101
- import { UserState as wa, UserTokenGenerateUserErrorCode as Ba } from "./shop-minis-platform/src/types/user.js";
102
- import { ContentCreateUserErrorCode as Ma, MinisContentStatus as Fa } from "./shop-minis-platform/src/types/content.js";
103
- import { Social as ba } from "./shop-minis-platform/src/types/share.js";
104
- import { DATA_FETCHING_DEFAULT_FETCH_POLICY as ka, DATA_FETCHING_DEFAULT_PAGE_SIZE as Na } from "./shop-minis-platform/src/constants.js";
90
+ import { useIntent as Zt } from "./hooks/intents/useIntent.js";
91
+ import { useOnMiniFocus as Jt } from "./hooks/events/useOnMiniFocus.js";
92
+ import { useOnMiniBlur as $t } from "./hooks/events/useOnMiniBlur.js";
93
+ import { useOnMiniClose as ea } from "./hooks/events/useOnMiniClose.js";
94
+ import { useOnAppStateChange as ta } from "./hooks/events/useOnAppStateChange.js";
95
+ import { useOnNavigateBack as pa } from "./hooks/events/useOnNavigateBack.js";
96
+ import { buildDeeplinkUrl as ma } from "./utils/buildDeeplinkUrl.js";
97
+ import { MiniEntityNotFoundError as sa, MiniError as la, MiniNetworkError as ua, formatError as fa } from "./utils/errors.js";
98
+ import { extractBrandTheme as ca, formatReviewCount as da, getFeaturedImages as Ca, normalizeRating as ga } from "./utils/merchant-card.js";
99
+ import { parseUrl as Aa } from "./utils/parseUrl.js";
100
+ import { dataURLToBlob as Pa, fileToDataUri as Ta, getResizedImageUrl as ha, getThumbhashBlobURL as Ia, getThumbhashDataURL as Ra } from "./utils/image.js";
101
+ import { formatMoney as wa } from "./utils/formatMoney.js";
102
+ import { UserState as Ea, UserTokenGenerateUserErrorCode as Ma } from "./shop-minis-platform/src/types/user.js";
103
+ import { ContentCreateUserErrorCode as Ua, MinisContentStatus as ba } from "./shop-minis-platform/src/types/content.js";
104
+ import { Social as ka } from "./shop-minis-platform/src/types/share.js";
105
+ import { DATA_FETCHING_DEFAULT_FETCH_POLICY as Oa, DATA_FETCHING_DEFAULT_PAGE_SIZE as _a } from "./shop-minis-platform/src/constants.js";
105
106
  export {
106
107
  Tr as Accordion,
107
108
  hr as AccordionContent,
@@ -142,10 +143,10 @@ export {
142
143
  me as CarouselNext,
143
144
  ne as CarouselPrevious,
144
145
  le as Checkbox,
145
- Ma as ContentCreateUserErrorCode,
146
+ Ua as ContentCreateUserErrorCode,
146
147
  cr as ContentWrapper,
147
- ka as DATA_FETCHING_DEFAULT_FETCH_POLICY,
148
- Na as DATA_FETCHING_DEFAULT_PAGE_SIZE,
148
+ Oa as DATA_FETCHING_DEFAULT_FETCH_POLICY,
149
+ _a as DATA_FETCHING_DEFAULT_PAGE_SIZE,
149
150
  o as DATA_NAVIGATION_TYPE_ATTRIBUTE,
150
151
  fe as Dialog,
151
152
  xe as DialogClose,
@@ -182,11 +183,11 @@ export {
182
183
  B as MerchantCardName,
183
184
  E as MerchantCardRating,
184
185
  b as MerchantCardSkeleton,
185
- ma as MiniEntityNotFoundError,
186
- na as MiniError,
187
- sa as MiniNetworkError,
186
+ sa as MiniEntityNotFoundError,
187
+ la as MiniError,
188
+ ua as MiniNetworkError,
188
189
  p as MinisContainer,
189
- Fa as MinisContentStatus,
190
+ ba as MinisContentStatus,
190
191
  q as MinisRouter,
191
192
  t as NAVIGATION_TYPES,
192
193
  u as ProductCard,
@@ -236,29 +237,29 @@ export {
236
237
  Co as SheetTitle,
237
238
  go as SheetTrigger,
238
239
  ho as Skeleton,
239
- ba as Social,
240
+ ka as Social,
240
241
  Dr as StaticArea,
241
242
  fr as TextInput,
242
243
  Ao as Toaster,
243
244
  or as Touchable,
244
245
  Q as TransitionLink,
245
- wa as UserState,
246
- Ba as UserTokenGenerateUserErrorCode,
246
+ Ea as UserState,
247
+ Ma as UserTokenGenerateUserErrorCode,
247
248
  lr as VideoPlayer,
248
249
  Qr as badgeVariants,
249
- pa as buildDeeplinkUrl,
250
- Aa as dataURLToBlob,
251
- fa as extractBrandTheme,
252
- Da as fileToDataUri,
253
- la as formatError,
254
- Ra as formatMoney,
255
- xa as formatReviewCount,
256
- ca as getFeaturedImages,
257
- Pa as getResizedImageUrl,
258
- Ta as getThumbhashBlobURL,
259
- ha as getThumbhashDataURL,
260
- da as normalizeRating,
261
- ga as parseUrl,
250
+ ma as buildDeeplinkUrl,
251
+ Pa as dataURLToBlob,
252
+ ca as extractBrandTheme,
253
+ Ta as fileToDataUri,
254
+ fa as formatError,
255
+ wa as formatMoney,
256
+ da as formatReviewCount,
257
+ Ca as getFeaturedImages,
258
+ ha as getResizedImageUrl,
259
+ Ia as getThumbhashBlobURL,
260
+ Ra as getThumbhashDataURL,
261
+ ga as normalizeRating,
262
+ Aa as parseUrl,
262
263
  Po as toast,
263
264
  xt as useARPreview,
264
265
  dt as useAsyncStorage,
@@ -274,13 +275,14 @@ export {
274
275
  zo as useGenerateUserToken,
275
276
  zt as useImagePicker,
276
277
  At as useImageUpload,
278
+ Zt as useIntent,
277
279
  Yt as useKeyboardAvoidingView,
278
280
  wt as useNavigateWithTransition,
279
- ea as useOnAppStateChange,
280
- Jt as useOnMiniBlur,
281
- $t as useOnMiniClose,
282
- Zt as useOnMiniFocus,
283
- ta as useOnNavigateBack,
281
+ ta as useOnAppStateChange,
282
+ $t as useOnMiniBlur,
283
+ ea as useOnMiniClose,
284
+ Jt as useOnMiniFocus,
285
+ pa as useOnNavigateBack,
284
286
  yo as useOrders,
285
287
  ut as usePopularProducts,
286
288
  Jo as useProduct,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shopify/shop-minis-react",
3
3
  "license": "SEE LICENSE IN LICENSE.txt",
4
- "version": "0.14.0",
4
+ "version": "0.15.0",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -93,6 +93,7 @@
93
93
  "@types/react-dom": "^19.1.2",
94
94
  "@vitest/coverage-v8": "^2.1.8",
95
95
  "@vitest/ui": "^2.1.8",
96
+ "eslint-formatter-codeframe": "^7.32.1",
96
97
  "jsdom": "^25.0.1",
97
98
  "react": "^19.1.0",
98
99
  "react-dom": "^19.1.0",
@@ -51,6 +51,9 @@ export * from './util/useImagePicker'
51
51
  export * from './util/useKeyboardAvoidingView'
52
52
  export * from './util/useRequestPermissions'
53
53
 
54
+ // - Intent Hooks
55
+ export * from './intents/useIntent'
56
+
54
57
  // - Event Hooks
55
58
  export * from './events/useOnMiniFocus'
56
59
  export * from './events/useOnMiniBlur'
@@ -0,0 +1,316 @@
1
+ import {renderHook} from '@testing-library/react'
2
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
3
+
4
+ import {useIntent} from './useIntent'
5
+
6
+ vi.mock('../navigation/useDeeplink', () => ({
7
+ useDeeplink: vi.fn(),
8
+ }))
9
+
10
+ const {useDeeplink} = await import('../navigation/useDeeplink')
11
+
12
+ describe('useIntent', () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks()
15
+ })
16
+
17
+ describe('without intent params', () => {
18
+ it('returns nulls when no query params', () => {
19
+ vi.mocked(useDeeplink).mockReturnValue({
20
+ path: '/',
21
+ queryParams: undefined,
22
+ hash: '',
23
+ })
24
+
25
+ const {result} = renderHook(() => useIntent())
26
+
27
+ expect(result.current).toEqual({query: null, data: null})
28
+ })
29
+
30
+ it('returns nulls when intentQuery is missing', () => {
31
+ vi.mocked(useDeeplink).mockReturnValue({
32
+ path: '/',
33
+ queryParams: {other: 'param'},
34
+ hash: '',
35
+ })
36
+
37
+ const {result} = renderHook(() => useIntent())
38
+
39
+ expect(result.current).toEqual({query: null, data: null})
40
+ })
41
+ })
42
+
43
+ describe('intent query parsing', () => {
44
+ it('parses action:type,value format', () => {
45
+ vi.mocked(useDeeplink).mockReturnValue({
46
+ path: '/',
47
+ queryParams: {
48
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
49
+ },
50
+ hash: '',
51
+ })
52
+
53
+ const {result} = renderHook(() => useIntent())
54
+
55
+ expect(result.current.query).toEqual({
56
+ action: 'try_on',
57
+ type: 'shopify/Product',
58
+ value: 'gid://shopify/Product/123',
59
+ })
60
+ expect(result.current.data).toBeNull()
61
+ })
62
+
63
+ it('parses action:type without value', () => {
64
+ vi.mocked(useDeeplink).mockReturnValue({
65
+ path: '/',
66
+ queryParams: {intentQuery: 'create:shopify/Product'},
67
+ hash: '',
68
+ })
69
+
70
+ const {result} = renderHook(() => useIntent())
71
+
72
+ expect(result.current.query).toEqual({
73
+ action: 'create',
74
+ type: 'shopify/Product',
75
+ value: null,
76
+ })
77
+ })
78
+
79
+ it('handles URL-encoded intentQuery', () => {
80
+ vi.mocked(useDeeplink).mockReturnValue({
81
+ path: '/',
82
+ queryParams: {
83
+ intentQuery:
84
+ 'try_on%3Ashopify%2FProduct%2Cgid%3A%2F%2Fshopify%2FProduct%2F123',
85
+ },
86
+ hash: '',
87
+ })
88
+
89
+ const {result} = renderHook(() => useIntent())
90
+
91
+ expect(result.current.query).toEqual({
92
+ action: 'try_on',
93
+ type: 'shopify/Product',
94
+ value: 'gid://shopify/Product/123',
95
+ })
96
+ })
97
+
98
+ it('returns null query for invalid format without colon', () => {
99
+ vi.mocked(useDeeplink).mockReturnValue({
100
+ path: '/',
101
+ queryParams: {intentQuery: 'invalid-no-colon'},
102
+ hash: '',
103
+ })
104
+
105
+ const {result} = renderHook(() => useIntent())
106
+
107
+ expect(result.current).toEqual({query: null, data: null})
108
+ })
109
+
110
+ it('returns null value for trailing comma', () => {
111
+ vi.mocked(useDeeplink).mockReturnValue({
112
+ path: '/',
113
+ queryParams: {intentQuery: 'create:shopify/Product,'},
114
+ hash: '',
115
+ })
116
+
117
+ const {result} = renderHook(() => useIntent())
118
+
119
+ expect(result.current.query).toEqual({
120
+ action: 'create',
121
+ type: 'shopify/Product',
122
+ value: null,
123
+ })
124
+ })
125
+
126
+ it('returns null query when type is empty', () => {
127
+ vi.mocked(useDeeplink).mockReturnValue({
128
+ path: '/',
129
+ queryParams: {intentQuery: 'try_on:'},
130
+ hash: '',
131
+ })
132
+
133
+ const {result} = renderHook(() => useIntent())
134
+
135
+ expect(result.current).toEqual({query: null, data: null})
136
+ })
137
+ })
138
+
139
+ describe('shorthand GID syntax', () => {
140
+ it('infers type and value from full GID', () => {
141
+ vi.mocked(useDeeplink).mockReturnValue({
142
+ path: '/',
143
+ queryParams: {
144
+ intentQuery: 'edit:gid://shopify/Product/123',
145
+ },
146
+ hash: '',
147
+ })
148
+
149
+ const {result} = renderHook(() => useIntent())
150
+
151
+ expect(result.current.query).toEqual({
152
+ action: 'edit',
153
+ type: 'shopify/Product',
154
+ value: 'gid://shopify/Product/123',
155
+ })
156
+ })
157
+
158
+ it('infers type with null value from GID without ID', () => {
159
+ vi.mocked(useDeeplink).mockReturnValue({
160
+ path: '/',
161
+ queryParams: {
162
+ intentQuery: 'create:gid://shopify/Product',
163
+ },
164
+ hash: '',
165
+ })
166
+
167
+ const {result} = renderHook(() => useIntent())
168
+
169
+ expect(result.current.query).toEqual({
170
+ action: 'create',
171
+ type: 'shopify/Product',
172
+ value: null,
173
+ })
174
+ })
175
+
176
+ it('parses intentData alongside shorthand GID', () => {
177
+ vi.mocked(useDeeplink).mockReturnValue({
178
+ path: '/',
179
+ queryParams: {
180
+ intentQuery: 'edit:gid://shopify/Product/123',
181
+ intentData: '{"title":"Updated"}',
182
+ },
183
+ hash: '',
184
+ })
185
+
186
+ const {result} = renderHook(() => useIntent())
187
+
188
+ expect(result.current.query).toEqual({
189
+ action: 'edit',
190
+ type: 'shopify/Product',
191
+ value: 'gid://shopify/Product/123',
192
+ })
193
+ expect(result.current.data).toEqual({title: 'Updated'})
194
+ })
195
+ })
196
+
197
+ describe('intent data parsing', () => {
198
+ it('parses JSON intentData', () => {
199
+ vi.mocked(useDeeplink).mockReturnValue({
200
+ path: '/',
201
+ queryParams: {
202
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
203
+ intentData: '{"variantId":"gid://shopify/ProductVariant/456"}',
204
+ },
205
+ hash: '',
206
+ })
207
+
208
+ const {result} = renderHook(() => useIntent())
209
+
210
+ expect(result.current.data).toEqual({
211
+ variantId: 'gid://shopify/ProductVariant/456',
212
+ })
213
+ })
214
+
215
+ it('parses URL-encoded intentData', () => {
216
+ vi.mocked(useDeeplink).mockReturnValue({
217
+ path: '/',
218
+ queryParams: {
219
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
220
+ intentData: '%7B%22variantId%22%3A%22456%22%7D',
221
+ },
222
+ hash: '',
223
+ })
224
+
225
+ const {result} = renderHook(() => useIntent())
226
+
227
+ expect(result.current.data).toEqual({variantId: '456'})
228
+ })
229
+
230
+ it('preserves percent-encoded values inside already-decoded JSON', () => {
231
+ vi.mocked(useDeeplink).mockReturnValue({
232
+ path: '/',
233
+ queryParams: {
234
+ intentQuery: 'edit:shopify/Product,gid://shopify/Product/123',
235
+ intentData: '{"redirect":"https%3A%2F%2Fexample.com"}',
236
+ },
237
+ hash: '',
238
+ })
239
+
240
+ const {result} = renderHook(() => useIntent())
241
+
242
+ expect(result.current.data).toEqual({
243
+ redirect: 'https%3A%2F%2Fexample.com',
244
+ })
245
+ })
246
+
247
+ it('returns null data when intentData is a JSON array', () => {
248
+ vi.mocked(useDeeplink).mockReturnValue({
249
+ path: '/',
250
+ queryParams: {
251
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
252
+ intentData: '[1,2,3]',
253
+ },
254
+ hash: '',
255
+ })
256
+
257
+ const {result} = renderHook(() => useIntent())
258
+
259
+ expect(result.current.query).not.toBeNull()
260
+ expect(result.current.data).toBeNull()
261
+ })
262
+
263
+ it('returns null data when intentData is a JSON primitive', () => {
264
+ vi.mocked(useDeeplink).mockReturnValue({
265
+ path: '/',
266
+ queryParams: {
267
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
268
+ intentData: '"just a string"',
269
+ },
270
+ hash: '',
271
+ })
272
+
273
+ const {result} = renderHook(() => useIntent())
274
+
275
+ expect(result.current.query).not.toBeNull()
276
+ expect(result.current.data).toBeNull()
277
+ })
278
+
279
+ it('returns null data for malformed JSON', () => {
280
+ vi.mocked(useDeeplink).mockReturnValue({
281
+ path: '/',
282
+ queryParams: {
283
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
284
+ intentData: 'not-valid-json',
285
+ },
286
+ hash: '',
287
+ })
288
+
289
+ const {result} = renderHook(() => useIntent())
290
+
291
+ expect(result.current.query).not.toBeNull()
292
+ expect(result.current.data).toBeNull()
293
+ })
294
+ })
295
+
296
+ describe('memoization', () => {
297
+ it('returns same reference when queryParams do not change', () => {
298
+ const queryParams = {
299
+ intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
300
+ }
301
+
302
+ vi.mocked(useDeeplink).mockReturnValue({
303
+ path: '/',
304
+ queryParams,
305
+ hash: '',
306
+ })
307
+
308
+ const {result, rerender} = renderHook(() => useIntent())
309
+ const firstResult = result.current
310
+
311
+ rerender()
312
+
313
+ expect(result.current).toBe(firstResult)
314
+ })
315
+ })
316
+ })
@@ -0,0 +1,111 @@
1
+ import {useMemo} from 'react'
2
+
3
+ import {useDeeplink} from '../navigation/useDeeplink'
4
+
5
+ /**
6
+ * Structured description of an intent, following the Shopify Intents API
7
+ * URI format: `action:type,value`
8
+ *
9
+ * @see https://shopify.dev/docs/api/admin-extensions/latest/target-apis/utility-apis/intents-api
10
+ */
11
+ export interface IntentQuery {
12
+ /** Verb describing the operation (e.g., 'try_on', 'create', 'edit') */
13
+ action: string
14
+ /** Resource type identifier (e.g., 'shopify/Product', 'shop/UserImage') */
15
+ type: string
16
+ /** Resource GID (e.g., 'gid://shopify/Product/123') if applicable */
17
+ value: string | null
18
+ }
19
+
20
+ export interface UseIntentReturn {
21
+ /** Parsed intent query, or null if no intent was passed */
22
+ query: IntentQuery | null
23
+ /** Additional JSON data passed with the intent, or null */
24
+ data: {[key: string]: unknown} | null
25
+ }
26
+
27
+ /**
28
+ * Parses an intent passed to this mini via deeplink.
29
+ *
30
+ * Follows the Shopify Intents API URI format (`action:type,value`) with
31
+ * an optional JSON data payload passed separately.
32
+ *
33
+ * @see https://shopify.dev/docs/api/admin-extensions/latest/target-apis/utility-apis/intents-api
34
+ *
35
+ * Deeplink format:
36
+ * ?intentQuery=action:type,value&intentData={"key":"value"}
37
+ *
38
+ * Examples:
39
+ * ?intentQuery=try_on:shopify/Product,gid://shopify/Product/123
40
+ * ?intentQuery=create:shopify/Product
41
+ * ?intentQuery=edit:shopify/Product,gid://shopify/Product/123&intentData={"variantId":"456"}
42
+ *
43
+ * Shorthand GID syntax (type inferred from GID):
44
+ * ?intentQuery=edit:gid://shopify/Product/123
45
+ * ?intentQuery=create:gid://shopify/Product
46
+ *
47
+ * Use this hook to receive intents from the host app (Host → Mini direction).
48
+ */
49
+ export function useIntent(): UseIntentReturn {
50
+ const {queryParams} = useDeeplink()
51
+
52
+ return useMemo(() => {
53
+ const raw = queryParams?.intentQuery
54
+
55
+ if (!raw) return {query: null, data: null}
56
+
57
+ let decoded: string
58
+ try {
59
+ decoded = decodeURIComponent(raw)
60
+ } catch {
61
+ decoded = raw
62
+ }
63
+
64
+ const colonIdx = decoded.indexOf(':')
65
+ if (colonIdx === -1) return {query: null, data: null}
66
+
67
+ const action = decoded.slice(0, colonIdx)
68
+ const rest = decoded.slice(colonIdx + 1)
69
+ const commaIdx = rest.indexOf(',')
70
+
71
+ let type = commaIdx === -1 ? rest : rest.slice(0, commaIdx)
72
+ let value = commaIdx === -1 ? null : rest.slice(commaIdx + 1) || null
73
+
74
+ // Shorthand GID syntax: edit:gid://shopify/Product/123
75
+ // Infer type from GID and use full GID as value
76
+ const gidMatch = type.match(/^gid:\/\/shopify\/(\w+)(?:\/(.+))?$/)
77
+ if (gidMatch) {
78
+ type = `shopify/${gidMatch[1]}`
79
+ value = gidMatch[2] ? `gid://shopify/${gidMatch[1]}/${gidMatch[2]}` : null
80
+ }
81
+
82
+ if (!type) return {query: null, data: null}
83
+
84
+ let data: {[key: string]: unknown} | null = null
85
+ const rawData = queryParams?.intentData
86
+ if (rawData) {
87
+ let parsed: unknown
88
+ try {
89
+ parsed = JSON.parse(rawData)
90
+ } catch {
91
+ try {
92
+ parsed = JSON.parse(decodeURIComponent(rawData))
93
+ } catch {
94
+ // malformed JSON — ignore
95
+ }
96
+ }
97
+ if (
98
+ parsed !== null &&
99
+ typeof parsed === 'object' &&
100
+ !Array.isArray(parsed)
101
+ ) {
102
+ data = parsed as {[key: string]: unknown}
103
+ }
104
+ }
105
+
106
+ return {
107
+ query: {action, type, value},
108
+ data,
109
+ }
110
+ }, [queryParams])
111
+ }