@occluze/tether 0.6.8 → 0.7.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.
- package/README.md +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/presets/barba.esm.js +19 -23
- package/dist/tether.account-pages.js +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +44 -68
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +24 -23
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/{order-lines.js → order-lines.ts} +20 -41
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/{page-events.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/{loader.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/{product-events.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
- package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
- package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
- package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
- package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
- package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
- package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
- package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
- package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
- package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
- package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
- package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
- package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
- package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
- package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
- package/dist/esm/chunks/search-OU75RNQL.js +0 -1
- package/src/account/addresses-view.js +0 -92
- package/src/account/browser-session.js +0 -359
- package/src/account/markup.js +0 -48
- package/src/account/operations.js +0 -180
- package/src/account/order-list.js +0 -91
- package/src/account/order-page.js +0 -192
- package/src/account/server-client.js +0 -172
- package/src/account/shared.js +0 -111
- package/src/account/shopify-auth.js +0 -150
- package/src/account-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- package/src/index.js +0 -353
- package/src/initial.js +0 -87
- package/src/lazy-iife.js +0 -116
- package/src/lazy.js +0 -39
- package/src/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- package/src/parts/account-pages.js +0 -6
- package/src/parts/account.js +0 -6
- package/src/parts/analytics.js +0 -6
- package/src/parts/diagnose.js +0 -6
- package/src/parts/extras.js +0 -6
- package/src/parts/markets.js +0 -6
- package/src/parts/search.js +0 -6
- package/src/presets/barba.js +0 -190
- package/src/product-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- package/types/astro.d.ts +0 -28
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -577
- package/types/server.d.ts +0 -182
package/src/client.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one Storefront API client: a fetch wrapper that adds the market (`@inContext`), retries what Shopify asks to be
|
|
3
|
+
* retried, and turns every failure into a `TetherError`, an answer whose `data` lacks what its caller reads included
|
|
4
|
+
* (`DataCheck`). The browser (`gql`, and `query` for `Tether.query()`) and the server helpers (`request` with their own
|
|
5
|
+
* config) share it.
|
|
6
|
+
*/
|
|
7
|
+
import { cfg, log } from './config.ts';
|
|
8
|
+
import { toCountry, toLanguage } from './market-codes.ts';
|
|
9
|
+
import { field, isRecord } from '../server/src/shared/json.ts';
|
|
10
|
+
import { messageOf } from '../server/src/shared/error-message.ts';
|
|
11
|
+
import type { TetherConfig } from './config.ts';
|
|
12
|
+
import type { CountryCode, LanguageCode } from './market-codes.ts';
|
|
13
|
+
|
|
14
|
+
export type TetherErrorCode =
|
|
15
|
+
| 'NOT_FOUND'
|
|
16
|
+
| 'NETWORK'
|
|
17
|
+
| 'NOT_CONFIGURED'
|
|
18
|
+
| 'NOT_LOGGED_IN'
|
|
19
|
+
| 'INVALID'
|
|
20
|
+
| 'INVALID_COUNTRY'
|
|
21
|
+
| 'INVALID_DISCOUNT'
|
|
22
|
+
| 'ERROR'
|
|
23
|
+
| `HTTP_${number}`;
|
|
24
|
+
export interface GraphQLError {
|
|
25
|
+
message: string;
|
|
26
|
+
extensions?: { code?: string };
|
|
27
|
+
}
|
|
28
|
+
/** The response shape the GraphQL spec sets. */
|
|
29
|
+
interface GraphQLResponse {
|
|
30
|
+
data?: unknown;
|
|
31
|
+
errors?: GraphQLError[];
|
|
32
|
+
}
|
|
33
|
+
export interface UserError {
|
|
34
|
+
message: string;
|
|
35
|
+
field?: string[] | null;
|
|
36
|
+
code?: string | null;
|
|
37
|
+
}
|
|
38
|
+
interface TetherErrorDetails {
|
|
39
|
+
/** Default `HTTP_<status>` with a status, else `ERROR`. */
|
|
40
|
+
code?: TetherErrorCode;
|
|
41
|
+
/** The HTTP status of the response that failed. */
|
|
42
|
+
status?: number;
|
|
43
|
+
/** The GraphQL errors of that response. */
|
|
44
|
+
errors?: GraphQLError[];
|
|
45
|
+
/** A mutation's user errors. */
|
|
46
|
+
userErrors?: readonly UserError[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class TetherError extends Error {
|
|
50
|
+
override name = 'TetherError' as const;
|
|
51
|
+
code: TetherErrorCode;
|
|
52
|
+
status: number | undefined;
|
|
53
|
+
errors: GraphQLError[] | undefined;
|
|
54
|
+
userErrors: readonly UserError[] | undefined;
|
|
55
|
+
|
|
56
|
+
constructor(message: string, { status, errors, userErrors, code }: TetherErrorDetails = {}) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.code = code ?? (status ? `HTTP_${status}` : 'ERROR');
|
|
59
|
+
this.status = status;
|
|
60
|
+
this.errors = errors;
|
|
61
|
+
this.userErrors = userErrors;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a request needs from a config: the browser's `cfg`, or a config the server helpers build. */
|
|
66
|
+
export type StorefrontConfig = Pick<
|
|
67
|
+
TetherConfig,
|
|
68
|
+
'domain' | 'token' | 'apiVersion' | 'country' | 'language' | 'maxAge' | 'retries'
|
|
69
|
+
> & { fetch?: typeof fetch };
|
|
70
|
+
/** Checks a query's `data` before its caller reads it as a `T`. */
|
|
71
|
+
export type DataCheck<T> = (data: unknown) => data is T;
|
|
72
|
+
/**
|
|
73
|
+
* The structure of JSON a check accepts: a `typeof` name, `'object'` for a plain object, `[item]` for a list of
|
|
74
|
+
* items, an object's fields, or a check of its own. Fields it doesn't name aren't checked, so a shape names only what
|
|
75
|
+
* its reader walks without a fallback.
|
|
76
|
+
*/
|
|
77
|
+
export type Shape =
|
|
78
|
+
| 'string'
|
|
79
|
+
| 'number'
|
|
80
|
+
| 'boolean'
|
|
81
|
+
| 'object'
|
|
82
|
+
| ((value: unknown) => boolean)
|
|
83
|
+
| readonly [Shape]
|
|
84
|
+
| { readonly [key: string]: Shape };
|
|
85
|
+
|
|
86
|
+
/** @returns True when `value` has the structure `shape` describes. */
|
|
87
|
+
export function matches(value: unknown, shape: Shape): boolean {
|
|
88
|
+
if (typeof shape === 'function') return shape(value);
|
|
89
|
+
if (typeof shape === 'string') return shape === 'object' ? isRecord(value) : typeof value === shape;
|
|
90
|
+
if (isShapeList(shape)) return Array.isArray(value) && value.every((item) => matches(item, shape[0]));
|
|
91
|
+
return isRecord(value) && Object.entries(shape).every(([key, inner]) => matches(value[key], inner));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `Array.isArray` doesn't narrow a readonly tuple out of a union. */
|
|
95
|
+
const isShapeList = (shape: Shape): shape is readonly [Shape] => Array.isArray(shape);
|
|
96
|
+
|
|
97
|
+
/** @returns The shape of `null` or a value with the structure `shape` describes. */
|
|
98
|
+
export const orNull =
|
|
99
|
+
(shape: Shape) =>
|
|
100
|
+
(value: unknown): boolean =>
|
|
101
|
+
value === null || matches(value, shape);
|
|
102
|
+
|
|
103
|
+
/** @returns A check that `data` has the structure `shape` describes, which `T` states. */
|
|
104
|
+
export const shaped =
|
|
105
|
+
<T>(shape: Shape): DataCheck<T> =>
|
|
106
|
+
(data): data is T =>
|
|
107
|
+
matches(data, shape);
|
|
108
|
+
/** `context: false` sends the query without `@inContext`. */
|
|
109
|
+
interface RequestOptions {
|
|
110
|
+
context?: boolean;
|
|
111
|
+
}
|
|
112
|
+
/** What the store's token may read beyond the defaults. */
|
|
113
|
+
export interface StoreAccess {
|
|
114
|
+
inventory: boolean;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A query's text, or a function that writes it for what the token may read: Tether's own queries, built from
|
|
118
|
+
* `fragments.ts`, leave out inventory for a token that can't read it.
|
|
119
|
+
*/
|
|
120
|
+
export type QueryText = string | ((access: StoreAccess) => string);
|
|
121
|
+
|
|
122
|
+
/** @returns The Storefront GraphQL endpoint of `conf`'s store. */
|
|
123
|
+
export function endpoint(conf: Pick<TetherConfig, 'domain' | 'apiVersion'> = cfg): string {
|
|
124
|
+
return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** GraphQL strings and `#` comments: text in them is never part of an operation's header. */
|
|
128
|
+
const STRINGS_AND_COMMENTS = /"(?:\\.|[^"\\\n])*"|#[^\n]*/g;
|
|
129
|
+
/** An operation's keyword, name and variable definitions: named (query Name($x: T)) and anonymous (query { … }). */
|
|
130
|
+
const OPERATION_START = /^\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Adds @inContext(country, language) to the first operation in the document, so prices, currency and translations
|
|
134
|
+
* follow the market. The header is found in a copy of the document whose strings and comments are blanked out, so a
|
|
135
|
+
* `)` in a default value or an `@inContext` in a comment or the selection set doesn't move or stop it; an operation
|
|
136
|
+
* that names its own `@inContext` keeps it. Tether's own documents start with their operation, and so do the ones
|
|
137
|
+
* `Tether.query()` is given in practice: a document that starts with a fragment is sent as written.
|
|
138
|
+
*/
|
|
139
|
+
function inContext(
|
|
140
|
+
query: string,
|
|
141
|
+
{ country, language }: { country: CountryCode | undefined; language: LanguageCode | undefined },
|
|
142
|
+
): string {
|
|
143
|
+
const args: string[] = [];
|
|
144
|
+
if (country) args.push(`country: ${country}`);
|
|
145
|
+
if (language) args.push(`language: ${language}`);
|
|
146
|
+
if (!args.length) return query;
|
|
147
|
+
const tag = `@inContext(${args.join(', ')})`;
|
|
148
|
+
const blanked = query.replace(STRINGS_AND_COMMENTS, (text) => ' '.repeat(text.length));
|
|
149
|
+
const selectionStart = blanked.indexOf('{');
|
|
150
|
+
if (blanked.slice(0, selectionStart).includes('@inContext')) return query;
|
|
151
|
+
// Shorthand: a document that starts with its selection set is a query without a keyword.
|
|
152
|
+
if (blanked.trimStart().startsWith('{')) return `query ${tag} ${query.slice(selectionStart)}`;
|
|
153
|
+
const header = OPERATION_START.exec(blanked);
|
|
154
|
+
if (!header) return query;
|
|
155
|
+
const end = header[0].length;
|
|
156
|
+
return `${query.slice(0, end).trimEnd()} ${tag} ${query.slice(end).trimStart()}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `inContext` for any country and language, as the public `Tether.withContext` takes them: one that isn't a valid
|
|
161
|
+
* code is left out, so page input never becomes query text.
|
|
162
|
+
*/
|
|
163
|
+
export function withContext(
|
|
164
|
+
query: string,
|
|
165
|
+
ctx: { country?: string | undefined; language?: string | undefined } = cfg,
|
|
166
|
+
): string {
|
|
167
|
+
const country = toCountry(ctx.country) ?? undefined;
|
|
168
|
+
const language = toLanguage(ctx.language) ?? undefined;
|
|
169
|
+
return inContext(query, { country, language });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Backoff after a network error or a 429/5xx: this times 2^attempt. */
|
|
173
|
+
const RETRY_BACKOFF_MS = 300;
|
|
174
|
+
/** Backoff after Shopify's `THROTTLED` error, which asks for a longer wait: this times 2^attempt. */
|
|
175
|
+
const THROTTLE_BACKOFF_MS = 500;
|
|
176
|
+
/** Shopify's status for a request its bot protection rejected; retrying doesn't help. */
|
|
177
|
+
const SECURITY_REJECTION = 430;
|
|
178
|
+
const MS_PER_SECOND = 1000;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `quantityAvailable` needs the token's `unauthenticated_read_product_inventory` scope; without it Shopify refuses the
|
|
182
|
+
* whole query with an error naming the field. That refusal is learned once per page and store: Tether's later queries
|
|
183
|
+
* to the store are written without the field (`StoreAccess`), so stock levels are unknown and everything else works,
|
|
184
|
+
* for one extra request.
|
|
185
|
+
*/
|
|
186
|
+
const INVENTORY_FIELD = 'quantityAvailable';
|
|
187
|
+
|
|
188
|
+
/** The stores (domain, version and token) whose token can't read inventory. */
|
|
189
|
+
const storesWithoutInventory = new Set<string>();
|
|
190
|
+
|
|
191
|
+
const storeOf = (conf: StorefrontConfig) => `${conf.domain} ${conf.apiVersion} ${conf.token}`;
|
|
192
|
+
|
|
193
|
+
/** @returns The text sent: with the market, and the cache's time bucket for a query. */
|
|
194
|
+
function queryText(conf: StorefrontConfig, query: QueryText, access: StoreAccess, context: boolean): string {
|
|
195
|
+
const written = typeof query === 'string' ? query : query(access);
|
|
196
|
+
const text = context ? inContext(written, conf) : written;
|
|
197
|
+
if (conf.maxAge <= 0 || /^\s*mutation\b/.test(text)) return text;
|
|
198
|
+
return `${text} #${Math.floor(Date.now() / (conf.maxAge * MS_PER_SECOND))}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
202
|
+
|
|
203
|
+
/** What a status means, for errors Shopify sends without a message (401/403 have none). */
|
|
204
|
+
function statusText(status: number, conf: StorefrontConfig): string {
|
|
205
|
+
if (status === 401 || status === 403) {
|
|
206
|
+
const adminHint = conf.token.startsWith('shpat_') ? ', not an Admin API token (shpat_…)' : '';
|
|
207
|
+
return `Shopify rejected the Storefront token for ${conf.domain} (${status}): use the public Storefront API token (Headless channel)${adminHint}`;
|
|
208
|
+
}
|
|
209
|
+
if (status === 404) {
|
|
210
|
+
return `Store or API version not found (404): check domain ${conf.domain} and apiVersion ${conf.apiVersion}`;
|
|
211
|
+
}
|
|
212
|
+
return `HTTP ${status} from ${conf.domain}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param value One entry of a response's `errors`.
|
|
217
|
+
* @returns The error, with an empty message when it has none.
|
|
218
|
+
*/
|
|
219
|
+
function toGraphQLError(value: unknown): GraphQLError {
|
|
220
|
+
const message = field(value, 'message');
|
|
221
|
+
const code = field(field(value, 'extensions'), 'code');
|
|
222
|
+
const text = typeof message === 'string' ? message : '';
|
|
223
|
+
return typeof code === 'string' ? { message: text, extensions: { code } } : { message: text };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param json A response body.
|
|
228
|
+
* @returns The body in the shape the GraphQL spec sets, `null` when it has another one.
|
|
229
|
+
*/
|
|
230
|
+
function toGraphQLResponse(json: unknown): GraphQLResponse | null {
|
|
231
|
+
if (!isRecord(json)) return null;
|
|
232
|
+
const { data, errors } = json;
|
|
233
|
+
if (errors === undefined || errors === null) return { data };
|
|
234
|
+
if (!Array.isArray(errors)) return null;
|
|
235
|
+
return { data, errors: errors.map(toGraphQLError) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** For a body that isn't JSON, isn't in the GraphQL shape or has `data` its caller's check refuses. */
|
|
239
|
+
function invalidResponse(res: Response, conf: StorefrontConfig): TetherError {
|
|
240
|
+
const text = res.ok ? `Invalid response (${res.status})` : statusText(res.status, conf);
|
|
241
|
+
return new TetherError(text, { status: res.status });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function graphQLFailure(res: Response, errors: GraphQLError[], conf: StorefrontConfig): TetherError {
|
|
245
|
+
const message = errors
|
|
246
|
+
.map((error) => error.message)
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
.join(' | ');
|
|
249
|
+
let text = message || 'Storefront API error';
|
|
250
|
+
if (!res.ok) text = res.status === 401 || res.status === 403 || !message ? statusText(res.status, conf) : message;
|
|
251
|
+
return new TetherError(text, { status: res.status, errors });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type Attempt<T> =
|
|
255
|
+
{ kind: 'data'; data: T } | { kind: 'retry'; backoffMs: number } | { kind: 'inventory-refused'; error: TetherError };
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One request. Failures that retrying can't fix throw.
|
|
259
|
+
*
|
|
260
|
+
* @param canRetry False on the last attempt: a retryable failure then throws too.
|
|
261
|
+
*/
|
|
262
|
+
async function attempt<T>(
|
|
263
|
+
conf: StorefrontConfig,
|
|
264
|
+
query: string,
|
|
265
|
+
variables: Record<string, unknown>,
|
|
266
|
+
isData: DataCheck<T>,
|
|
267
|
+
canRetry: boolean,
|
|
268
|
+
): Promise<Attempt<T>> {
|
|
269
|
+
let res;
|
|
270
|
+
try {
|
|
271
|
+
res = await (conf.fetch ?? fetch)(endpoint(conf), {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json', 'X-Shopify-Storefront-Access-Token': conf.token },
|
|
274
|
+
body: JSON.stringify({ query, variables }),
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (canRetry) return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
|
|
278
|
+
throw new TetherError(`Network error: ${messageOf(error)}`, {
|
|
279
|
+
code: 'NETWORK',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (res.status === SECURITY_REJECTION) {
|
|
283
|
+
throw new TetherError('Shopify security rejection (430)', { status: SECURITY_REJECTION });
|
|
284
|
+
}
|
|
285
|
+
if (canRetry && (res.status === 429 || res.status >= 500)) {
|
|
286
|
+
return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let json: unknown;
|
|
290
|
+
try {
|
|
291
|
+
json = await res.json();
|
|
292
|
+
} catch {
|
|
293
|
+
throw invalidResponse(res, conf);
|
|
294
|
+
}
|
|
295
|
+
const body = toGraphQLResponse(json);
|
|
296
|
+
if (!body) throw invalidResponse(res, conf);
|
|
297
|
+
const { data, errors = [] } = body;
|
|
298
|
+
if (errors.length) {
|
|
299
|
+
if (query.includes(INVENTORY_FIELD) && errors.some((error) => error.message.includes(INVENTORY_FIELD))) {
|
|
300
|
+
return { kind: 'inventory-refused', error: graphQLFailure(res, errors, conf) };
|
|
301
|
+
}
|
|
302
|
+
if (canRetry && errors.some((error) => error.extensions?.code === 'THROTTLED')) {
|
|
303
|
+
return { kind: 'retry', backoffMs: THROTTLE_BACKOFF_MS };
|
|
304
|
+
}
|
|
305
|
+
throw graphQLFailure(res, errors, conf);
|
|
306
|
+
}
|
|
307
|
+
if (!res.ok) throw new TetherError(statusText(res.status, conf), { status: res.status });
|
|
308
|
+
if (!isData(data)) throw invalidResponse(res, conf);
|
|
309
|
+
return { kind: 'data', data };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One Storefront API request, for any config: the browser's shared one (`gql`) or one the server helpers build.
|
|
314
|
+
* Queries carry a time bucket so Shopify's cache of an identical query text is at most `maxAge` seconds old. A query
|
|
315
|
+
* written for the store's access leaves out inventory once the token turns out not to read it (`INVENTORY_FIELD`);
|
|
316
|
+
* a fixed query text is the caller's own, and that refusal is its error.
|
|
317
|
+
*
|
|
318
|
+
* @param isData Checks that `data` has what the caller then reads, so a malformed answer fails here.
|
|
319
|
+
* @throws {TetherError} `NOT_CONFIGURED` without a domain or token, `NETWORK` when the store can't be reached,
|
|
320
|
+
* `HTTP_<status>` for an HTTP or GraphQL failure, or for `data` that `isData` refuses.
|
|
321
|
+
*/
|
|
322
|
+
export async function request<T>(
|
|
323
|
+
conf: StorefrontConfig,
|
|
324
|
+
query: QueryText,
|
|
325
|
+
variables: Record<string, unknown>,
|
|
326
|
+
isData: DataCheck<T>,
|
|
327
|
+
{ context = true }: RequestOptions = {},
|
|
328
|
+
): Promise<T> {
|
|
329
|
+
if (!conf.domain || !conf.token) {
|
|
330
|
+
throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
|
|
331
|
+
}
|
|
332
|
+
const store = storeOf(conf);
|
|
333
|
+
let retries = 0;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const sent = queryText(conf, query, { inventory: !storesWithoutInventory.has(store) }, context);
|
|
336
|
+
const outcome = await attempt(conf, sent, variables, isData, retries < conf.retries);
|
|
337
|
+
switch (outcome.kind) {
|
|
338
|
+
case 'data':
|
|
339
|
+
return outcome.data;
|
|
340
|
+
case 'inventory-refused':
|
|
341
|
+
if (typeof query === 'string') throw outcome.error;
|
|
342
|
+
log(`the token can't read inventory: ${INVENTORY_FIELD} is left out from now on`);
|
|
343
|
+
storesWithoutInventory.add(store);
|
|
344
|
+
break;
|
|
345
|
+
case 'retry':
|
|
346
|
+
retries += 1;
|
|
347
|
+
log('retry', retries);
|
|
348
|
+
await sleep(outcome.backoffMs * 2 ** retries);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** `request` with the browser's shared config. */
|
|
355
|
+
export function gql<T>(
|
|
356
|
+
query: QueryText,
|
|
357
|
+
variables: Record<string, unknown>,
|
|
358
|
+
isData: DataCheck<T>,
|
|
359
|
+
options?: RequestOptions,
|
|
360
|
+
): Promise<T> {
|
|
361
|
+
return request(cfg, query, variables, isData, options);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The check of `Tether.query()` and the server's `query()`, whose callers read `data` themselves. */
|
|
365
|
+
export const hasData = (data: unknown): data is object => typeof data === 'object' && data !== null;
|
|
366
|
+
|
|
367
|
+
/** `Tether.query()`: the caller's own query text decides the shape of `data`, and the caller's `T` states it. */
|
|
368
|
+
export function query<T>(
|
|
369
|
+
text: QueryText,
|
|
370
|
+
variables: Record<string, unknown> = {},
|
|
371
|
+
options?: RequestOptions,
|
|
372
|
+
): Promise<T> {
|
|
373
|
+
return gql(text, variables, hasData, options) as Promise<T>;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @param payload A mutation's payload.
|
|
378
|
+
* @param name The mutation, for the error message.
|
|
379
|
+
* @throws {TetherError} `ERROR` on an empty payload or on user errors (in `userErrors`).
|
|
380
|
+
*/
|
|
381
|
+
export function unwrap<Payload extends { userErrors?: readonly UserError[] | null }>(
|
|
382
|
+
payload: Payload | null | undefined,
|
|
383
|
+
name: string,
|
|
384
|
+
): Payload {
|
|
385
|
+
if (!payload) throw new TetherError(`${name}: empty response`);
|
|
386
|
+
const userErrors = payload.userErrors ?? [];
|
|
387
|
+
if (userErrors.length) {
|
|
388
|
+
throw new TetherError(userErrors.map((error) => error.message).join(' | '), { userErrors });
|
|
389
|
+
}
|
|
390
|
+
return payload;
|
|
391
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* first child of [tether-list] is the card template; every card becomes a product scope, so all product attributes
|
|
4
4
|
* work inside cards (including quick-add with a subscription toggle).
|
|
5
5
|
*/
|
|
6
|
-
import { reportError } from './config.
|
|
7
|
-
import { TetherError } from './client.
|
|
6
|
+
import { reportError } from './config.ts';
|
|
7
|
+
import { TetherError } from './client.ts';
|
|
8
8
|
import {
|
|
9
9
|
attr,
|
|
10
10
|
sel,
|
|
@@ -19,47 +19,42 @@ import {
|
|
|
19
19
|
notifyLayoutChanged,
|
|
20
20
|
busy,
|
|
21
21
|
show,
|
|
22
|
-
} from './dom.
|
|
23
|
-
import { getProducts, getRecommendations } from './loader.
|
|
24
|
-
import { setScopeStatus, isNotFound, resolveProductKey } from './status.
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
} from './dom.ts';
|
|
23
|
+
import { getProducts, getRecommendations } from './loader.ts';
|
|
24
|
+
import { setScopeStatus, isNotFound, resolveProductKey } from './status.ts';
|
|
25
|
+
import type { Product } from './fragments.ts';
|
|
26
|
+
import type { Collection, PageInfo, ProductQuery } from './loader.ts';
|
|
27
27
|
|
|
28
28
|
/** `tether-products="url:q"`: the search in the page's `?q=`, for a results page on a static site. */
|
|
29
29
|
const URL_QUERY_SOURCE = 'url:';
|
|
30
30
|
|
|
31
31
|
export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
* @param {EventTarget | null} target
|
|
35
|
-
* @returns {target is Element}
|
|
36
|
-
*/
|
|
37
|
-
const isElement = (target) => target instanceof window.Element;
|
|
33
|
+
const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
|
|
38
34
|
|
|
39
35
|
/**
|
|
40
|
-
* @param
|
|
41
|
-
* @returns
|
|
36
|
+
* @param raw A `tether-limit`.
|
|
37
|
+
* @returns The limit; `undefined` for none, zero or not a number.
|
|
42
38
|
*/
|
|
43
|
-
const toLimit = (raw) => parseInt(raw ?? '', 10) || undefined;
|
|
39
|
+
const toLimit = (raw: string | null): number | undefined => parseInt(raw ?? '', 10) || undefined;
|
|
44
40
|
|
|
45
41
|
/**
|
|
46
|
-
* @param
|
|
47
|
-
* @returns
|
|
42
|
+
* @param raw A `tether-intent`.
|
|
43
|
+
* @returns Shopify's recommendation intent, `RELATED` unless `COMPLEMENTARY` is written.
|
|
48
44
|
*/
|
|
49
|
-
const toIntent = (raw
|
|
45
|
+
const toIntent = (raw: string | null): 'RELATED' | 'COMPLEMENTARY' =>
|
|
46
|
+
raw === 'COMPLEMENTARY' ? 'COMPLEMENTARY' : 'RELATED';
|
|
50
47
|
|
|
51
48
|
export class ListScope {
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
readonly root: HTMLElement;
|
|
50
|
+
products: Product[] = [];
|
|
51
|
+
pageInfo: PageInfo | null = null;
|
|
52
|
+
collection: Collection | null = null;
|
|
53
|
+
/** Counts loads, so only the latest one renders. */
|
|
54
|
+
loadToken = 0;
|
|
55
|
+
|
|
56
|
+
constructor(root: HTMLElement) {
|
|
54
57
|
this.root = root;
|
|
55
|
-
/** @type {Product[]} */
|
|
56
|
-
this.products = [];
|
|
57
|
-
/** @type {PageInfo | null} */
|
|
58
|
-
this.pageInfo = null;
|
|
59
|
-
/** @type {Collection | null} */
|
|
60
|
-
this.collection = null;
|
|
61
|
-
/** Counts loads, so only the latest one renders. */
|
|
62
|
-
this.loadToken = 0;
|
|
63
58
|
setScopeStatus(root, 'loading');
|
|
64
59
|
root.addEventListener('click', (event) => {
|
|
65
60
|
if (!isElement(event.target)) return;
|
|
@@ -70,16 +65,14 @@ export class ListScope {
|
|
|
70
65
|
});
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
$(selector) {
|
|
68
|
+
$(selector: string) {
|
|
75
69
|
return scoped(this.root, selector, listSelector());
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
/**
|
|
79
|
-
options() {
|
|
72
|
+
/** The collection or search query of the list's attributes. */
|
|
73
|
+
options(): ProductQuery {
|
|
80
74
|
const { root } = this;
|
|
81
|
-
|
|
82
|
-
const query = { reverse: get(root, 'reverse') === 'true' };
|
|
75
|
+
const query: ProductQuery = { reverse: get(root, 'reverse') === 'true' };
|
|
83
76
|
const first = toLimit(get(root, 'limit'));
|
|
84
77
|
if (first) query.first = first;
|
|
85
78
|
const sortKey = get(root, 'sort');
|
|
@@ -99,10 +92,9 @@ export class ListScope {
|
|
|
99
92
|
* The product of a recommendations list: a handle, `url:…` (one dynamic product page), or empty inside a product
|
|
100
93
|
* scope for that product.
|
|
101
94
|
*
|
|
102
|
-
* @returns {Promise<Product[]>}
|
|
103
95
|
* @throws {TetherError} `NOT_FOUND` when there is no product to recommend for.
|
|
104
96
|
*/
|
|
105
|
-
async loadRecommendations() {
|
|
97
|
+
async loadRecommendations(): Promise<Product[]> {
|
|
106
98
|
let { key } = resolveProductKey(this.root, window.location, 'recommendations');
|
|
107
99
|
const scope = this.root.closest(sel('product'));
|
|
108
100
|
if (!key && scope) ({ key } = resolveProductKey(scope));
|
|
@@ -110,8 +102,8 @@ export class ListScope {
|
|
|
110
102
|
return getRecommendations(key, toIntent(get(this.root, 'intent')));
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
/** @param
|
|
114
|
-
async load(more = false) {
|
|
105
|
+
/** @param more Appends the next page instead of replacing the products. */
|
|
106
|
+
async load(more = false): Promise<void> {
|
|
115
107
|
this.loadToken += 1;
|
|
116
108
|
const token = this.loadToken;
|
|
117
109
|
const stale = () => token !== this.loadToken;
|