@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.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
@@ -1,38 +1,75 @@
1
1
  /**
2
- * Optional part, loaded on first use: market detection, `select[tether-country-select]`, `tether-country-bind` and
3
- * `Tether.setCountry()`. See markets.js for the detection rules.
2
+ * Optional part, loaded on first use: market detection, the store's countries (`Tether.getLocalization()`),
3
+ * `select[tether-country-select]`, `tether-country-bind` and `Tether.setCountry()`. See markets.ts for the detection
4
+ * rules.
4
5
  */
5
- import { cfg, reportError } from './config.js';
6
- import { TetherError } from './client.js';
7
- import { Cart } from './cart.js';
8
- import { listSelector } from './collection.js';
9
- import { clearProductCache } from './loader.js';
10
- import { attr, sel, get, emit } from './dom.js';
11
- import { within, pruneLive, liveLists, liveProducts, mountedProducts, mountProduct } from './mounting.js';
12
- import { rememberMarket, getLocalization, clearLocalization } from './markets.js';
13
- import { toCountry, toLanguage } from './market-codes.js';
14
- /** @import { LocalizedCountry, Localization } from './markets.js' */
15
- /** @import { CountryCode } from './market-codes.js' */
16
-
17
- export { detectMarket, getLocalization } from './markets.js';
6
+ import { cfg, reportError } from './config.ts';
7
+ import { gql, shaped, TetherError } from './client.ts';
8
+ import { Cart } from './cart.ts';
9
+ import { listSelector } from './collection.ts';
10
+ import { clearProductCache } from './loader.ts';
11
+ import { attr, sel, get, emit } from './dom.ts';
12
+ import { within, pruneLive, liveLists, liveProducts, mountedProducts, mountProduct } from './mounting.ts';
13
+ import { rememberMarket } from './markets.ts';
14
+ import { toCountry, toLanguage } from './market-codes.ts';
15
+ import type { LocalizedCountry, Localization } from './markets.ts';
16
+ import type { CountryCode, LanguageCode } from './market-codes.ts';
17
+
18
+ export { detectMarket } from './markets.ts';
18
19
 
19
20
  const DEFAULT_LABEL = '{name} ({currency})';
20
21
  const DEFAULT_BIND = 'name';
21
22
 
23
+ let localizationPromise: Promise<Localization> | null = null;
24
+
25
+ /** Forgets the cached localization, for when country names change language. */
26
+ function clearLocalization(): void {
27
+ localizationPromise = null;
28
+ }
29
+
30
+ const LOCALIZATION_QUERY = `query TetherLocalization {
31
+ localization {
32
+ country { isoCode name currency { isoCode symbol } }
33
+ language { isoCode name }
34
+ availableCountries { isoCode name currency { isoCode symbol } availableLanguages { isoCode name } }
35
+ }
36
+ }`;
37
+
38
+ /** A country with the code, name and currency the country select and binds read. */
39
+ const COUNTRY = { isoCode: 'string', name: 'string', currency: 'object' } as const;
40
+ const isLocalizationAnswer = shaped<{ localization: Localization }>({
41
+ localization: { country: COUNTRY, availableCountries: [COUNTRY] },
42
+ });
43
+
22
44
  /**
23
- * @typedef {object} SetCountryOptions
24
- * @property {string | null | undefined} [language] A Shopify language; `null` clears it. Default: the
25
- * `markets.locales` entry for the country, else unchanged.
26
- * @property {string | undefined} [locale] The money format. Default: the `markets.locales` entry, else unchanged.
27
- * @property {boolean | undefined} [remember] Save the choice for the next pages (default true).
45
+ * The countries the store sells to (Shopify Markets), with their currency. Cached per page; a failed request is not
46
+ * cached, so the next call tries again.
28
47
  */
48
+ export function getLocalization(): Promise<Localization> {
49
+ localizationPromise ??= gql(LOCALIZATION_QUERY, {}, isLocalizationAnswer).then(
50
+ (data) => data.localization,
51
+ (error: unknown) => {
52
+ localizationPromise = null;
53
+ throw error;
54
+ },
55
+ );
56
+ return localizationPromise;
57
+ }
58
+
59
+ interface SetCountryOptions {
60
+ /** A Shopify language; `null` clears it. Default: the `markets.locales` entry for the country, else unchanged. */
61
+ language?: string | null | undefined;
62
+ /** The money format. Default: the `markets.locales` entry, else unchanged. */
63
+ locale?: string | undefined;
64
+ /** Save the choice for the next pages (default true). */
65
+ remember?: boolean | undefined;
66
+ }
29
67
 
30
68
  /**
31
- * @param {LocalizedCountry} country
32
- * @param {string} field A `tether-country-bind` value or a label placeholder: `name`, `code`, `currency`, `symbol`.
33
- * @returns {string} `''` for any other field.
69
+ * @param field A `tether-country-bind` value or a label placeholder: `name`, `code`, `currency`, `symbol`.
70
+ * @returns `''` for any other field.
34
71
  */
35
- function countryField(country, field) {
72
+ function countryField(country: LocalizedCountry, field: string): string {
36
73
  switch (field) {
37
74
  case 'name':
38
75
  return country.name;
@@ -47,21 +84,15 @@ function countryField(country, field) {
47
84
  }
48
85
  }
49
86
 
50
- /**
51
- * @param {string} template `tether-country-label`, e.g. `{name} ({currency})`.
52
- * @param {LocalizedCountry} country
53
- */
54
- const fillLabel = (template, country) =>
55
- template.replace(/\{(name|code|currency|symbol)\}/g, (_match, /** @type {string} */ field) =>
56
- countryField(country, field),
57
- );
87
+ /** @param template `tether-country-label`, e.g. `{name} ({currency})`. */
88
+ const fillLabel = (template: string, country: LocalizedCountry) =>
89
+ template.replace(/\{(name|code|currency|symbol)\}/g, (_match, field: string) => countryField(country, field));
58
90
 
59
91
  /**
60
- * @param {HTMLSelectElement} select
61
- * @param {LocalizedCountry[]} countries The store's countries, sorted by name.
62
- * @param {string} current The country in use.
92
+ * @param countries The store's countries, sorted by name.
93
+ * @param current The country in use.
63
94
  */
64
- function fillCountrySelect(select, countries, current) {
95
+ function fillCountrySelect(select: HTMLSelectElement, countries: LocalizedCountry[], current: string): void {
65
96
  const template = select.getAttribute(attr('country-label')) || DEFAULT_LABEL;
66
97
  // tether-countries="NL,DE,BE": only these, in that order; otherwise every country of the store.
67
98
  const only = (select.getAttribute(attr('countries')) || '')
@@ -80,12 +111,7 @@ function fillCountrySelect(select, countries, current) {
80
111
  select.disabled = listed.length < 2 && !select.hasAttribute(attr('always-enabled'));
81
112
  }
82
113
 
83
- /**
84
- * @param {HTMLSelectElement[]} selects
85
- * @param {HTMLElement[]} binds
86
- * @param {Localization} localization
87
- */
88
- function fillCountries(selects, binds, localization) {
114
+ function fillCountries(selects: HTMLSelectElement[], binds: HTMLElement[], localization: Localization): void {
89
115
  const current = (cfg.country || localization.country.isoCode).toUpperCase();
90
116
  const countries = [...localization.availableCountries].sort((a, b) =>
91
117
  a.name.localeCompare(b.name, cfg.locale || undefined),
@@ -103,36 +129,34 @@ function fillCountries(selects, binds, localization) {
103
129
  * Fills `select[tether-country-select]` with the countries the store sells to (Shopify Markets), and
104
130
  * `tether-country-bind="name|code|currency|symbol"` with the current country. `tether-country-label` sets the option
105
131
  * text from `{name}`, `{code}`, `{currency}` and `{symbol}`. A failure is reported, never thrown.
106
- *
107
- * @param {ParentNode} [root]
108
- * @returns {Promise<void>}
109
132
  */
110
- export function renderCountrySelects(root = document) {
111
- const selects = /** @type {HTMLSelectElement[]} */ (within(root, `select${sel('country-select')}`));
133
+ export function renderCountrySelects(root: ParentNode = document): Promise<void> {
134
+ // The selector matches `select` elements only.
135
+ const selects = within(root, `select${sel('country-select')}`) as HTMLSelectElement[];
112
136
  const binds = within(root, sel('country-bind'));
113
137
  if (!selects.length && !binds.length) return Promise.resolve();
114
138
  return getLocalization()
115
139
  .then((localization) => {
116
140
  fillCountries(selects, binds, localization);
117
141
  })
118
- .catch((/** @type {unknown} */ error) => {
142
+ .catch((error: unknown) => {
119
143
  reportError(error, 'country select');
120
144
  });
121
145
  }
122
146
 
123
- /**
124
- * @param {CountryCode} country
125
- * @returns {{ country?: string, language?: string, locale?: string } | undefined} The `markets.locales` entry for it.
126
- */
127
- const localeEntryFor = (country) =>
147
+ /** The `markets.locales` entry for `country`. */
148
+ const localeEntryFor = (country: CountryCode) =>
128
149
  Object.values(cfg.markets?.locales ?? {}).find((entry) => toCountry(entry.country) === country);
129
150
 
130
151
  /**
131
- * @param {string | null | undefined} language The `setCountry()` option.
132
- * @param {{ language?: string } | undefined} entry The `markets.locales` entry for the country.
133
- * @returns {string | null | undefined} The option, else the entry's language (which may have none), else unchanged.
152
+ * @param language The `setCountry()` option.
153
+ * @param entry The `markets.locales` entry for the country.
154
+ * @returns The option, else the entry's language (which may have none), else unchanged.
134
155
  */
135
- function languageFor(language, entry) {
156
+ function languageFor(
157
+ language: string | null | undefined,
158
+ entry: { language?: string } | undefined,
159
+ ): LanguageCode | null | undefined {
136
160
  if (language !== undefined) return toLanguage(language);
137
161
  if (!entry) return cfg.language;
138
162
  return entry.language ? toLanguage(entry.language) : undefined;
@@ -141,17 +165,15 @@ function languageFor(language, entry) {
141
165
  /**
142
166
  * Loads every live product and list again in the market `cfg` now holds, and moves an existing cart to `country`.
143
167
  *
144
- * @param {CountryCode} country
145
- * @returns {Promise<unknown>[]} The cart update and the list loads.
168
+ * @returns The cart update and the list loads.
146
169
  */
147
- function rerenderInMarket(country) {
170
+ function rerenderInMarket(country: CountryCode): Promise<unknown>[] {
148
171
  clearProductCache();
149
- /** @type {Promise<unknown>[]} */
150
- const jobs = [];
172
+ const jobs: Promise<unknown>[] = [];
151
173
  // Only an existing cart moves; switching country never creates one.
152
174
  if (Cart.id()) {
153
175
  jobs.push(
154
- Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((/** @type {unknown} */ error) => {
176
+ Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((error: unknown) => {
155
177
  reportError(error, 'cart country');
156
178
  }),
157
179
  );
@@ -173,19 +195,20 @@ function rerenderInMarket(country) {
173
195
  *
174
196
  * Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
175
197
  *
176
- * @param {string} code
177
- * @param {SetCountryOptions} [options]
178
- * @returns {Promise<typeof Cart.state>} The cart once everything is in the new market.
198
+ * @returns The cart once everything is in the new market.
179
199
  * @throws {TetherError} `INVALID_COUNTRY` for anything that isn't a country code.
180
200
  */
181
- export async function setCountry(code, { language, locale, remember = true } = {}) {
201
+ export async function setCountry(
202
+ code: string,
203
+ { language, locale, remember = true }: SetCountryOptions = {},
204
+ ): Promise<typeof Cart.state> {
182
205
  const country = toCountry(code);
183
206
  if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
184
207
  const entry = localeEntryFor(country);
185
208
  const ownLocale = locale || entry?.locale;
186
209
  const nextLocale = ownLocale || cfg.locale;
187
210
  const nextLanguage = languageFor(language, entry);
188
- const changed = country !== cfg.country?.toUpperCase() || nextLanguage !== cfg.language || nextLocale !== cfg.locale;
211
+ const changed = country !== cfg.country || nextLanguage !== cfg.language || nextLocale !== cfg.locale;
189
212
  cfg.country = country;
190
213
  cfg.language = nextLanguage || undefined;
191
214
  cfg.locale = nextLocale;
package/src/markets.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Markets: which country and language prices, currency and translations follow.
3
+ *
4
+ * markets: {
5
+ * detect: true, // or a list: ['saved', 'server', 'webflow']
6
+ * locales: { // Webflow Localization: locale subdirectory or <html lang>
7
+ * de: { country: 'DE', language: 'DE', locale: 'de-DE' },
8
+ * 'fr-be': { country: 'BE', language: 'FR' },
9
+ * },
10
+ * cookie: 'tether_country', // a cookie your server or Worker sets (from cf-ipcountry)
11
+ * remember: true, // keep the visitor's tether-country-select choice
12
+ * }
13
+ *
14
+ * Sources, first match wins per field:
15
+ * saved the visitor's own choice (localStorage), from tether-country-select / Tether.setCountry()
16
+ * server window.TETHER_COUNTRY, <meta name="tether-country" content="DE">, or the cookie
17
+ * webflow locale subdirectory (/de/...) or <html lang="de-DE">, through `locales`
18
+ * then the static `country` / `language` config. Without `detect`, only `saved` applies, so a country select keeps
19
+ * working across pages; `detect: false` turns even that off. Tether never calls an IP lookup service: a country from
20
+ * the visitor's IP only comes from the site's own server (see README).
21
+ */
22
+ import { MARKET_SOURCES, cfg, parseMarkets } from './config.ts';
23
+ import { toCountry, toLanguage } from './market-codes.ts';
24
+ import { getStored, setStored } from './storage.ts';
25
+ import type { MarketsConfig } from './config.ts';
26
+ import type { CountryCode, LanguageCode } from './market-codes.ts';
27
+
28
+ /** A market a visitor chose, as `setCountry()` stores it. */
29
+ export interface Market {
30
+ country: CountryCode;
31
+ language: LanguageCode | undefined;
32
+ /** An `Intl` locale for money and dates. */
33
+ locale: string | undefined;
34
+ }
35
+
36
+ export type MarketSource = 'saved' | 'server' | 'webflow';
37
+ type MarketField = 'country' | 'language' | 'locale';
38
+ type MarketSources = Partial<Record<MarketField, MarketSource>>;
39
+
40
+ /** The fields detection found, and the source each came from. */
41
+ export interface DetectedMarket {
42
+ country?: CountryCode;
43
+ language?: LanguageCode;
44
+ locale?: string;
45
+ /** Absent when `detect: false` turns detection off. */
46
+ sources?: MarketSources;
47
+ }
48
+
49
+ /** What detection reads, passed in so it runs on any document. */
50
+ interface MarketEnv {
51
+ document: Pick<Document, 'cookie' | 'querySelector' | 'documentElement'> | null;
52
+ location: Pick<Location, 'pathname'> | null;
53
+ /** `localStorage`, read through `getStored`, which finds nothing where the browser blocks it. */
54
+ storage: Pick<Storage, 'getItem'>;
55
+ /** Where `TETHER_COUNTRY` and `TETHER_LANGUAGE` are read from (`window`). */
56
+ global: object;
57
+ }
58
+
59
+ /** A source's raw values. */
60
+ interface MarketHint {
61
+ country?: unknown;
62
+ language?: unknown;
63
+ locale?: unknown;
64
+ }
65
+
66
+ interface NamedCode {
67
+ isoCode: string;
68
+ name: string;
69
+ }
70
+
71
+ interface Currency {
72
+ isoCode: string;
73
+ symbol: string;
74
+ }
75
+
76
+ export type LocalizedCountry = NamedCode & { currency: Currency };
77
+
78
+ /** The store's markets, from the Storefront API's `localization`. */
79
+ export interface Localization {
80
+ /** The country of the current `@inContext`. */
81
+ country: LocalizedCountry;
82
+ language: NamedCode;
83
+ availableCountries: (LocalizedCountry & { availableLanguages: NamedCode[] })[];
84
+ }
85
+
86
+ const DEFAULT_COOKIE = 'tether_country';
87
+
88
+ /** The localStorage key of the visitor's market: `<prefix>:market:<domain>`. */
89
+ export const storageKey = (): string => `${cfg.prefix}:market:${cfg.domain}`;
90
+
91
+ const pageStorage: Pick<Storage, 'getItem'> = { getItem: (key) => getStored(key) };
92
+
93
+ /** The page this script runs on. */
94
+ function pageEnv(): MarketEnv {
95
+ const hasWindow = typeof window !== 'undefined';
96
+ return {
97
+ document: typeof document !== 'undefined' ? document : null,
98
+ location: hasWindow ? window.location : null,
99
+ storage: pageStorage,
100
+ global: hasWindow ? window : {},
101
+ };
102
+ }
103
+
104
+ /** The JSON value of `text`, `null` for text that isn't JSON (a key another script wrote). */
105
+ function parseJsonOrNull(text: string): unknown {
106
+ try {
107
+ return JSON.parse(text) as unknown;
108
+ } catch (error) {
109
+ if (error instanceof SyntaxError) return null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ /** `value` as a market, when it has a valid country. A language or locale that isn't valid is left out. */
115
+ function toMarket(value: unknown): Market | null {
116
+ if (typeof value !== 'object' || value === null) return null;
117
+ const country = toCountry(Reflect.get(value, 'country'));
118
+ if (!country) return null;
119
+ const locale: unknown = Reflect.get(value, 'locale');
120
+ return {
121
+ country,
122
+ language: toLanguage(Reflect.get(value, 'language')) ?? undefined,
123
+ locale: typeof locale === 'string' && locale ? locale : undefined,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The visitor's saved market (`setCountry()`, `tether-country-select`), validated: storage is written by other tabs,
129
+ * older versions and other scripts too.
130
+ *
131
+ * @param storage Default `localStorage`.
132
+ * @returns `null` when nothing valid is saved.
133
+ */
134
+ export function readSavedMarket(storage: Pick<Storage, 'getItem'> = pageStorage): Market | null {
135
+ const text = storage.getItem(storageKey());
136
+ return text ? toMarket(parseJsonOrNull(text)) : null;
137
+ }
138
+
139
+ function readCookie(doc: Pick<Document, 'cookie'> | null, name: string): string | null {
140
+ if (!doc || !name) return null;
141
+ const cookie = doc.cookie
142
+ .split(';')
143
+ .map((part) => part.trim())
144
+ .find((part) => part.startsWith(`${name}=`));
145
+ return cookie ? decodeURIComponent(cookie.slice(name.length + 1)) : null;
146
+ }
147
+
148
+ /** The entry of `locales` for `key`, ignoring case. */
149
+ function lookupLocale(locales: MarketsConfig['locales'], key: string | undefined): MarketHint | null {
150
+ if (!locales || !key) return null;
151
+ const wanted = key.toLowerCase();
152
+ const found = Object.keys(locales).find((name) => name.toLowerCase() === wanted);
153
+ return found ? (locales[found] ?? null) : null;
154
+ }
155
+
156
+ function readServerHint(markets: MarketsConfig, env: MarketEnv): MarketHint {
157
+ const meta = (name: string) => env.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
158
+ const globalCountry: unknown = Reflect.get(env.global, 'TETHER_COUNTRY');
159
+ const globalLanguage: unknown = Reflect.get(env.global, 'TETHER_LANGUAGE');
160
+ const pageCountry = meta('tether-country') || readCookie(env.document, markets.cookie || DEFAULT_COOKIE);
161
+ return {
162
+ country: globalCountry || pageCountry,
163
+ language: globalLanguage || meta('tether-language'),
164
+ };
165
+ }
166
+
167
+ function readWebflowLocale(markets: MarketsConfig, env: MarketEnv): MarketHint | null {
168
+ const segment = (env.location?.pathname ?? '/').split('/').find((part) => part !== '');
169
+ const fromPath = lookupLocale(markets.locales, segment);
170
+ if (fromPath) return fromPath;
171
+ const lang = env.document?.documentElement.getAttribute('lang');
172
+ if (!lang) return null;
173
+ const mapped = lookupLocale(markets.locales, lang) ?? lookupLocale(markets.locales, lang.split('-')[0]);
174
+ if (mapped) return { locale: lang, ...mapped };
175
+ if (markets.locales && markets.strict) return null;
176
+ // Unmapped: the language from <html lang>, a country only from a region subtag (de-CH -> CH).
177
+ const region = lang.split('-')[1];
178
+ return { language: lang, country: region?.length === 2 ? region : null, locale: lang };
179
+ }
180
+
181
+ function readSource(source: MarketSource, markets: MarketsConfig, env: MarketEnv): MarketHint | null {
182
+ switch (source) {
183
+ case 'saved':
184
+ return markets.remember === false ? null : readSavedMarket(env.storage);
185
+ case 'server':
186
+ return readServerHint(markets, env);
187
+ case 'webflow':
188
+ return readWebflowLocale(markets, env);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * @param detect Validated: a list holds only known sources.
194
+ * @returns Without `detect`, only the visitor's own choice from a country select.
195
+ */
196
+ function sourcesFor(detect: MarketsConfig['detect']): readonly MarketSource[] {
197
+ if (!detect) return ['saved'];
198
+ return detect === true ? MARKET_SOURCES : detect;
199
+ }
200
+
201
+ /**
202
+ * The market the page's sources point to. Reads the DOM and storage only, so it can run on any `env`.
203
+ *
204
+ * @returns Only the fields found; `{}` with `detect: false`.
205
+ */
206
+ export function detectMarket(
207
+ markets: MarketsConfig | null | undefined = cfg.markets,
208
+ env: MarketEnv = pageEnv(),
209
+ ): DetectedMarket {
210
+ // The config's own `markets` was validated by `configure()`; one passed in by the site's code is validated here.
211
+ const config = (markets === cfg.markets ? markets : parseMarkets(markets)) ?? {};
212
+ if (config.detect === false) return {};
213
+ const sources: MarketSources = {};
214
+ const found: DetectedMarket = { sources };
215
+ for (const source of sourcesFor(config.detect)) {
216
+ const hint = readSource(source, config, env);
217
+ if (!hint) continue;
218
+ const country = toCountry(hint.country);
219
+ const language = toLanguage(hint.language);
220
+ const locale = typeof hint.locale === 'string' && hint.locale ? hint.locale : null;
221
+ if (country && !found.country) {
222
+ found.country = country;
223
+ sources.country = source;
224
+ }
225
+ if (language && !found.language) {
226
+ found.language = language;
227
+ sources.language = source;
228
+ }
229
+ if (locale && !found.locale) {
230
+ found.locale = locale;
231
+ sources.locale = source;
232
+ }
233
+ }
234
+ return found;
235
+ }
236
+
237
+ /** Saves the visitor's choice for the next pages, unless `markets.remember` is false. */
238
+ export function rememberMarket(market: {
239
+ country: CountryCode;
240
+ language?: string | null | undefined;
241
+ locale?: string | null | undefined;
242
+ }): void {
243
+ if (cfg.markets?.remember === false) return;
244
+ const saved = { country: market.country, language: market.language || undefined, locale: market.locale || undefined };
245
+ setStored(storageKey(), JSON.stringify(saved));
246
+ }
package/src/markup.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * How Tether finds its markup: attribute names carry the configured prefix (`tether-product`, or `wfs-product` with
3
+ * `prefix: 'wfs'`), and so do the selectors and the DOM events built from them.
4
+ */
5
+ import { cfg } from './config.ts';
6
+ import type { ShopifyCart } from './cart/store.ts';
7
+ import type { CartLineInput } from './cart/mutations.ts';
8
+ import type { CartWarning } from './config.ts';
9
+ import type { Product, ProductVariant } from './fragments.ts';
10
+ import type { Collection } from './loader.ts';
11
+ import type { ProductScope } from './product.ts';
12
+ import type { Purchase } from './product-state.ts';
13
+ import type { SearchResults } from './search.ts';
14
+
15
+ /** @returns The prefixed attribute name: `product` → `tether-product`. */
16
+ export const attr = (name: string): string => `${cfg.prefix}-${name}`;
17
+
18
+ /** @returns A selector for the attribute, with that exact value when given. */
19
+ export const sel = (name: string, value?: string): string =>
20
+ value === undefined ? `[${attr(name)}]` : `[${attr(name)}="${value}"]`;
21
+
22
+ export const get = (el: Element, name: string): string | null => el.getAttribute(attr(name));
23
+
24
+ export const has = (el: Element, name: string): boolean => el.hasAttribute(attr(name));
25
+
26
+ /**
27
+ * Tether's attributes go on HTML elements, so matches are typed as such. An SVG element that carries one still has
28
+ * the `style`, `dataset` and attribute methods Tether uses.
29
+ *
30
+ * @returns The matches in `root`, none without a root.
31
+ */
32
+ export function qsa(root: ParentNode | null | undefined, selector: string): HTMLElement[] {
33
+ if (!root) return [];
34
+ return [...root.querySelectorAll<HTMLElement>(selector)];
35
+ }
36
+
37
+ /** @returns Matches in `root` whose nearest `scopeSelector` ancestor is `root` itself, not a nested scope. */
38
+ export const scoped = (root: Element | null | undefined, selector: string, scopeSelector: string): HTMLElement[] =>
39
+ qsa(root, selector).filter((el) => el.closest(scopeSelector) === root);
40
+
41
+ /**
42
+ * The events Tether dispatches, as `<prefix>:<name>` (`tether:cart-updated`), bubbling up to `document`, with the
43
+ * detail each carries. `emit` takes only these, and `Tether.on()` passes on only the events `emit` dispatched, so a listener gets the
44
+ * detail its event really has.
45
+ */
46
+ export interface TetherEvents {
47
+ ready: { cart: ShopifyCart | null };
48
+ 'product-ready': { product: Product; scope: ProductScope };
49
+ 'product-updated': { product: Product; scope: ProductScope };
50
+ /** `key` is null when the scope names no product; `source` is what it named. */
51
+ 'product-not-found': { key: string | null; source: string };
52
+ 'variant-changed': {
53
+ product: Product;
54
+ variant: ProductVariant;
55
+ selected: Record<string, string>;
56
+ purchase: Purchase['kind'];
57
+ sellingPlanId: string | null;
58
+ };
59
+ 'product-added': { product: Product; variant: ProductVariant; line: CartLineInput };
60
+ added: { cart: ShopifyCart; lines: CartLineInput[] };
61
+ 'group-added': { group: string | null; lines: CartLineInput[] };
62
+ 'cart-warning': { warnings: readonly CartWarning[] };
63
+ 'cart-updated': { cart: ShopifyCart | null; reason: string };
64
+ 'cart-toggled': { open: boolean };
65
+ 'cart-error': { error: unknown };
66
+ checkout: { url: string; cart: ShopifyCart | null };
67
+ 'list-ready': { products: Product[]; collection: Collection | null };
68
+ /** A list's cards were drawn (again): `container` holds them. */
69
+ 'list-rendered': { container: Element };
70
+ /** The search part's suggestions for `term`. */
71
+ search: SearchResults & { term: string };
72
+ 'country-changed': { country: string; language: string | undefined };
73
+ 'discount-applied': { code: string };
74
+ 'discount-invalid': { code: string };
75
+ 'layout-changed': { reason: string };
76
+ error: { error: unknown; context: string; key?: string | null };
77
+ 'account-login': Record<string, never>;
78
+ 'account-logout': Record<string, never>;
79
+ /** A login that failed: `expired` (it took too long, or came back to another browser) or `error`. */
80
+ 'account-error': { error: 'expired' | 'error' };
81
+ 'account-updated': { what: 'profile' | 'addresses' | 'subscriptions' | 'orders' };
82
+ }
83
+
84
+ /** The events the analytics part reports, which the core buffers from `init()` until the part has loaded. */
85
+ export const ANALYTICS_EVENTS = [
86
+ 'product-ready',
87
+ 'list-ready',
88
+ 'cart-updated',
89
+ 'cart-toggled',
90
+ 'checkout',
91
+ 'search',
92
+ ] as const satisfies readonly (keyof TetherEvents)[];
93
+
94
+ export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[number];
95
+
96
+ /**
97
+ * The events Tether created, with the name each was created under. Any script can dispatch a `<prefix>:<name>` event
98
+ * with any detail; only an event in here carries the detail `TetherEvents` gives its name.
99
+ */
100
+ const ownEvents = new WeakMap<Event, keyof TetherEvents>();
101
+
102
+ /** @returns The bubbling event `<prefix>:<name>`, known to `isOwnEvent`. */
103
+ export function ownEvent<Name extends keyof TetherEvents>(
104
+ name: Name,
105
+ detail: TetherEvents[Name],
106
+ ): CustomEvent<TetherEvents[Name]> {
107
+ const event = new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true });
108
+ ownEvents.set(event, name);
109
+ return event;
110
+ }
111
+
112
+ /** @returns True for an event `ownEvent` created as `name`, false for any other, such as one another script dispatched. */
113
+ export function isOwnEvent<Name extends keyof TetherEvents>(
114
+ event: Event,
115
+ name: Name,
116
+ ): event is CustomEvent<TetherEvents[Name]> {
117
+ return ownEvents.get(event) === name;
118
+ }
119
+
120
+ /** Dispatches the bubbling event `<prefix>:<name>`. */
121
+ export function emit<Name extends keyof TetherEvents>(
122
+ name: Name,
123
+ detail: TetherEvents[Name],
124
+ target: EventTarget = document,
125
+ ): void {
126
+ target.dispatchEvent(ownEvent(name, detail));
127
+ }