@shopify/shop-minis-react 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/intents/useIntent.js +45 -0
- package/dist/hooks/intents/useIntent.js.map +1 -0
- package/dist/index.js +45 -43
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/hooks/index.ts +3 -0
- package/src/hooks/intents/useIntent.test.ts +330 -0
- package/src/hooks/intents/useIntent.ts +113 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useMemo as f } from "react";
|
|
2
|
+
import { useDeeplink as p } from "../navigation/useDeeplink.js";
|
|
3
|
+
function q() {
|
|
4
|
+
const { queryParams: o } = p();
|
|
5
|
+
return f(() => {
|
|
6
|
+
const u = o?.intentQuery;
|
|
7
|
+
if (!u) return { query: null, data: null };
|
|
8
|
+
let e;
|
|
9
|
+
try {
|
|
10
|
+
e = decodeURIComponent(u);
|
|
11
|
+
} catch {
|
|
12
|
+
e = u;
|
|
13
|
+
}
|
|
14
|
+
const c = e.indexOf(":");
|
|
15
|
+
if (c === -1) return { query: null, data: null };
|
|
16
|
+
const s = e.slice(0, c);
|
|
17
|
+
if (!s) return { query: null, data: null };
|
|
18
|
+
const l = e.slice(c + 1), r = l.indexOf(",");
|
|
19
|
+
let a = r === -1 ? l : l.slice(0, r), d = r === -1 ? null : l.slice(r + 1) || null;
|
|
20
|
+
const n = a.match(/^gid:\/\/shopify\/(\w+)(?:\/(.+))?$/);
|
|
21
|
+
if (n && (a = `shopify/${n[1]}`, d = n[2] ? `gid://shopify/${n[1]}/${n[2]}` : null), !a) return { query: null, data: null };
|
|
22
|
+
let y = null;
|
|
23
|
+
const i = o?.intentData;
|
|
24
|
+
if (i) {
|
|
25
|
+
let t;
|
|
26
|
+
try {
|
|
27
|
+
t = JSON.parse(i);
|
|
28
|
+
} catch {
|
|
29
|
+
try {
|
|
30
|
+
t = JSON.parse(decodeURIComponent(i));
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
t !== null && typeof t == "object" && !Array.isArray(t) && (y = t);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
query: { action: s, type: a, value: d },
|
|
38
|
+
data: y
|
|
39
|
+
};
|
|
40
|
+
}, [o]);
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
q as useIntent
|
|
44
|
+
};
|
|
45
|
+
//# 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 if (!action) return {query: null, data: null}\n\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;AACxC,QAAI,CAACC,EAAQ,QAAO,EAAC,OAAO,MAAM,MAAM,KAAI;AAE5C,UAAMC,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 {
|
|
91
|
-
import {
|
|
92
|
-
import {
|
|
93
|
-
import {
|
|
94
|
-
import {
|
|
95
|
-
import {
|
|
96
|
-
import {
|
|
97
|
-
import {
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
100
|
-
import {
|
|
101
|
-
import {
|
|
102
|
-
import {
|
|
103
|
-
import {
|
|
104
|
-
import {
|
|
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
|
-
|
|
146
|
+
Ua as ContentCreateUserErrorCode,
|
|
146
147
|
cr as ContentWrapper,
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
sa as MiniEntityNotFoundError,
|
|
187
|
+
la as MiniError,
|
|
188
|
+
ua as MiniNetworkError,
|
|
188
189
|
p as MinisContainer,
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
246
|
+
Ea as UserState,
|
|
247
|
+
Ma as UserTokenGenerateUserErrorCode,
|
|
247
248
|
lr as VideoPlayer,
|
|
248
249
|
Qr as badgeVariants,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
4
|
+
"version": "0.15.1",
|
|
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",
|
package/src/hooks/index.ts
CHANGED
|
@@ -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,330 @@
|
|
|
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 query when action is empty', () => {
|
|
111
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
112
|
+
path: '/',
|
|
113
|
+
queryParams: {
|
|
114
|
+
intentQuery: ':shopify/Product,gid://shopify/Product/123',
|
|
115
|
+
},
|
|
116
|
+
hash: '',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const {result} = renderHook(() => useIntent())
|
|
120
|
+
|
|
121
|
+
expect(result.current).toEqual({query: null, data: null})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns null value for trailing comma', () => {
|
|
125
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
126
|
+
path: '/',
|
|
127
|
+
queryParams: {intentQuery: 'create:shopify/Product,'},
|
|
128
|
+
hash: '',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const {result} = renderHook(() => useIntent())
|
|
132
|
+
|
|
133
|
+
expect(result.current.query).toEqual({
|
|
134
|
+
action: 'create',
|
|
135
|
+
type: 'shopify/Product',
|
|
136
|
+
value: null,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('returns null query when type is empty', () => {
|
|
141
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
142
|
+
path: '/',
|
|
143
|
+
queryParams: {intentQuery: 'try_on:'},
|
|
144
|
+
hash: '',
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const {result} = renderHook(() => useIntent())
|
|
148
|
+
|
|
149
|
+
expect(result.current).toEqual({query: null, data: null})
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('shorthand GID syntax', () => {
|
|
154
|
+
it('infers type and value from full GID', () => {
|
|
155
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
156
|
+
path: '/',
|
|
157
|
+
queryParams: {
|
|
158
|
+
intentQuery: 'edit:gid://shopify/Product/123',
|
|
159
|
+
},
|
|
160
|
+
hash: '',
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const {result} = renderHook(() => useIntent())
|
|
164
|
+
|
|
165
|
+
expect(result.current.query).toEqual({
|
|
166
|
+
action: 'edit',
|
|
167
|
+
type: 'shopify/Product',
|
|
168
|
+
value: 'gid://shopify/Product/123',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('infers type with null value from GID without ID', () => {
|
|
173
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
174
|
+
path: '/',
|
|
175
|
+
queryParams: {
|
|
176
|
+
intentQuery: 'create:gid://shopify/Product',
|
|
177
|
+
},
|
|
178
|
+
hash: '',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const {result} = renderHook(() => useIntent())
|
|
182
|
+
|
|
183
|
+
expect(result.current.query).toEqual({
|
|
184
|
+
action: 'create',
|
|
185
|
+
type: 'shopify/Product',
|
|
186
|
+
value: null,
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('parses intentData alongside shorthand GID', () => {
|
|
191
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
192
|
+
path: '/',
|
|
193
|
+
queryParams: {
|
|
194
|
+
intentQuery: 'edit:gid://shopify/Product/123',
|
|
195
|
+
intentData: '{"title":"Updated"}',
|
|
196
|
+
},
|
|
197
|
+
hash: '',
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const {result} = renderHook(() => useIntent())
|
|
201
|
+
|
|
202
|
+
expect(result.current.query).toEqual({
|
|
203
|
+
action: 'edit',
|
|
204
|
+
type: 'shopify/Product',
|
|
205
|
+
value: 'gid://shopify/Product/123',
|
|
206
|
+
})
|
|
207
|
+
expect(result.current.data).toEqual({title: 'Updated'})
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('intent data parsing', () => {
|
|
212
|
+
it('parses JSON intentData', () => {
|
|
213
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
214
|
+
path: '/',
|
|
215
|
+
queryParams: {
|
|
216
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
217
|
+
intentData: '{"variantId":"gid://shopify/ProductVariant/456"}',
|
|
218
|
+
},
|
|
219
|
+
hash: '',
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const {result} = renderHook(() => useIntent())
|
|
223
|
+
|
|
224
|
+
expect(result.current.data).toEqual({
|
|
225
|
+
variantId: 'gid://shopify/ProductVariant/456',
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('parses URL-encoded intentData', () => {
|
|
230
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
231
|
+
path: '/',
|
|
232
|
+
queryParams: {
|
|
233
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
234
|
+
intentData: '%7B%22variantId%22%3A%22456%22%7D',
|
|
235
|
+
},
|
|
236
|
+
hash: '',
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const {result} = renderHook(() => useIntent())
|
|
240
|
+
|
|
241
|
+
expect(result.current.data).toEqual({variantId: '456'})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('preserves percent-encoded values inside already-decoded JSON', () => {
|
|
245
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
246
|
+
path: '/',
|
|
247
|
+
queryParams: {
|
|
248
|
+
intentQuery: 'edit:shopify/Product,gid://shopify/Product/123',
|
|
249
|
+
intentData: '{"redirect":"https%3A%2F%2Fexample.com"}',
|
|
250
|
+
},
|
|
251
|
+
hash: '',
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const {result} = renderHook(() => useIntent())
|
|
255
|
+
|
|
256
|
+
expect(result.current.data).toEqual({
|
|
257
|
+
redirect: 'https%3A%2F%2Fexample.com',
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('returns null data when intentData is a JSON array', () => {
|
|
262
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
263
|
+
path: '/',
|
|
264
|
+
queryParams: {
|
|
265
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
266
|
+
intentData: '[1,2,3]',
|
|
267
|
+
},
|
|
268
|
+
hash: '',
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const {result} = renderHook(() => useIntent())
|
|
272
|
+
|
|
273
|
+
expect(result.current.query).not.toBeNull()
|
|
274
|
+
expect(result.current.data).toBeNull()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('returns null data when intentData is a JSON primitive', () => {
|
|
278
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
279
|
+
path: '/',
|
|
280
|
+
queryParams: {
|
|
281
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
282
|
+
intentData: '"just a string"',
|
|
283
|
+
},
|
|
284
|
+
hash: '',
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const {result} = renderHook(() => useIntent())
|
|
288
|
+
|
|
289
|
+
expect(result.current.query).not.toBeNull()
|
|
290
|
+
expect(result.current.data).toBeNull()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('returns null data for malformed JSON', () => {
|
|
294
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
295
|
+
path: '/',
|
|
296
|
+
queryParams: {
|
|
297
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
298
|
+
intentData: 'not-valid-json',
|
|
299
|
+
},
|
|
300
|
+
hash: '',
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const {result} = renderHook(() => useIntent())
|
|
304
|
+
|
|
305
|
+
expect(result.current.query).not.toBeNull()
|
|
306
|
+
expect(result.current.data).toBeNull()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('memoization', () => {
|
|
311
|
+
it('returns same reference when queryParams do not change', () => {
|
|
312
|
+
const queryParams = {
|
|
313
|
+
intentQuery: 'try_on:shopify/Product,gid://shopify/Product/123',
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
vi.mocked(useDeeplink).mockReturnValue({
|
|
317
|
+
path: '/',
|
|
318
|
+
queryParams,
|
|
319
|
+
hash: '',
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const {result, rerender} = renderHook(() => useIntent())
|
|
323
|
+
const firstResult = result.current
|
|
324
|
+
|
|
325
|
+
rerender()
|
|
326
|
+
|
|
327
|
+
expect(result.current).toBe(firstResult)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
if (!action) return {query: null, data: null}
|
|
69
|
+
|
|
70
|
+
const rest = decoded.slice(colonIdx + 1)
|
|
71
|
+
const commaIdx = rest.indexOf(',')
|
|
72
|
+
|
|
73
|
+
let type = commaIdx === -1 ? rest : rest.slice(0, commaIdx)
|
|
74
|
+
let value = commaIdx === -1 ? null : rest.slice(commaIdx + 1) || null
|
|
75
|
+
|
|
76
|
+
// Shorthand GID syntax: edit:gid://shopify/Product/123
|
|
77
|
+
// Infer type from GID and use full GID as value
|
|
78
|
+
const gidMatch = type.match(/^gid:\/\/shopify\/(\w+)(?:\/(.+))?$/)
|
|
79
|
+
if (gidMatch) {
|
|
80
|
+
type = `shopify/${gidMatch[1]}`
|
|
81
|
+
value = gidMatch[2] ? `gid://shopify/${gidMatch[1]}/${gidMatch[2]}` : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!type) return {query: null, data: null}
|
|
85
|
+
|
|
86
|
+
let data: {[key: string]: unknown} | null = null
|
|
87
|
+
const rawData = queryParams?.intentData
|
|
88
|
+
if (rawData) {
|
|
89
|
+
let parsed: unknown
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(rawData)
|
|
92
|
+
} catch {
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(decodeURIComponent(rawData))
|
|
95
|
+
} catch {
|
|
96
|
+
// malformed JSON — ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
parsed !== null &&
|
|
101
|
+
typeof parsed === 'object' &&
|
|
102
|
+
!Array.isArray(parsed)
|
|
103
|
+
) {
|
|
104
|
+
data = parsed as {[key: string]: unknown}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
query: {action, type, value},
|
|
110
|
+
data,
|
|
111
|
+
}
|
|
112
|
+
}, [queryParams])
|
|
113
|
+
}
|