@occluze/tether 0.7.0 → 0.7.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/README.md +21 -21
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
- package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
- package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
- package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
- package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
- package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
- package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
- package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
- package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
- package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
- package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +18 -3
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +19 -6
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +162 -112
- package/dist/tether.markets.js +18 -3
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +2 -2
- package/dist/types/src/client.d.ts +53 -19
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/package.json +1 -2
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
package/src/markets-ui.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
5
|
-
*/
|
|
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';
|
|
19
|
-
|
|
20
|
-
const DEFAULT_LABEL = '{name} ({currency})';
|
|
21
|
-
const DEFAULT_BIND = 'name';
|
|
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
|
-
|
|
44
|
-
/**
|
|
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.
|
|
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
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* @param field A `tether-country-bind` value or a label placeholder: `name`, `code`, `currency`, `symbol`.
|
|
70
|
-
* @returns `''` for any other field.
|
|
71
|
-
*/
|
|
72
|
-
function countryField(country: LocalizedCountry, field: string): string {
|
|
73
|
-
switch (field) {
|
|
74
|
-
case 'name':
|
|
75
|
-
return country.name;
|
|
76
|
-
case 'code':
|
|
77
|
-
return country.isoCode;
|
|
78
|
-
case 'currency':
|
|
79
|
-
return country.currency.isoCode;
|
|
80
|
-
case 'symbol':
|
|
81
|
-
return country.currency.symbol;
|
|
82
|
-
default:
|
|
83
|
-
return '';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
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));
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* @param countries The store's countries, sorted by name.
|
|
93
|
-
* @param current The country in use.
|
|
94
|
-
*/
|
|
95
|
-
function fillCountrySelect(select: HTMLSelectElement, countries: LocalizedCountry[], current: string): void {
|
|
96
|
-
const template = select.getAttribute(attr('country-label')) || DEFAULT_LABEL;
|
|
97
|
-
// tether-countries="NL,DE,BE": only these, in that order; otherwise every country of the store.
|
|
98
|
-
const only = (select.getAttribute(attr('countries')) || '')
|
|
99
|
-
.toUpperCase()
|
|
100
|
-
.split(/[\s,]+/)
|
|
101
|
-
.filter(Boolean);
|
|
102
|
-
const listed = only.length
|
|
103
|
-
? only
|
|
104
|
-
.map((code) => countries.find((country) => country.isoCode === code))
|
|
105
|
-
.filter((country) => country !== undefined)
|
|
106
|
-
: countries;
|
|
107
|
-
select.replaceChildren(...listed.map((country) => new Option(fillLabel(template, country), country.isoCode)));
|
|
108
|
-
select.value = current;
|
|
109
|
-
// A country the store doesn't sell to selects nothing.
|
|
110
|
-
if (select.value !== current) select.selectedIndex = -1;
|
|
111
|
-
select.disabled = listed.length < 2 && !select.hasAttribute(attr('always-enabled'));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function fillCountries(selects: HTMLSelectElement[], binds: HTMLElement[], localization: Localization): void {
|
|
115
|
-
const current = (cfg.country || localization.country.isoCode).toUpperCase();
|
|
116
|
-
const countries = [...localization.availableCountries].sort((a, b) =>
|
|
117
|
-
a.name.localeCompare(b.name, cfg.locale || undefined),
|
|
118
|
-
);
|
|
119
|
-
const inUse = countries.find((country) => country.isoCode === current) ?? localization.country;
|
|
120
|
-
selects.forEach((select) => {
|
|
121
|
-
fillCountrySelect(select, countries, current);
|
|
122
|
-
});
|
|
123
|
-
binds.forEach((el) => {
|
|
124
|
-
el.textContent = countryField(inUse, get(el, 'country-bind') || DEFAULT_BIND);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Fills `select[tether-country-select]` with the countries the store sells to (Shopify Markets), and
|
|
130
|
-
* `tether-country-bind="name|code|currency|symbol"` with the current country. `tether-country-label` sets the option
|
|
131
|
-
* text from `{name}`, `{code}`, `{currency}` and `{symbol}`. A failure is reported, never thrown.
|
|
132
|
-
*/
|
|
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[];
|
|
136
|
-
const binds = within(root, sel('country-bind'));
|
|
137
|
-
if (!selects.length && !binds.length) return Promise.resolve();
|
|
138
|
-
return getLocalization()
|
|
139
|
-
.then((localization) => {
|
|
140
|
-
fillCountries(selects, binds, localization);
|
|
141
|
-
})
|
|
142
|
-
.catch((error: unknown) => {
|
|
143
|
-
reportError(error, 'country select');
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** The `markets.locales` entry for `country`. */
|
|
148
|
-
const localeEntryFor = (country: CountryCode) =>
|
|
149
|
-
Object.values(cfg.markets?.locales ?? {}).find((entry) => toCountry(entry.country) === country);
|
|
150
|
-
|
|
151
|
-
/**
|
|
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.
|
|
155
|
-
*/
|
|
156
|
-
function languageFor(
|
|
157
|
-
language: string | null | undefined,
|
|
158
|
-
entry: { language?: string } | undefined,
|
|
159
|
-
): LanguageCode | null | undefined {
|
|
160
|
-
if (language !== undefined) return toLanguage(language);
|
|
161
|
-
if (!entry) return cfg.language;
|
|
162
|
-
return entry.language ? toLanguage(entry.language) : undefined;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Loads every live product and list again in the market `cfg` now holds, and moves an existing cart to `country`.
|
|
167
|
-
*
|
|
168
|
-
* @returns The cart update and the list loads.
|
|
169
|
-
*/
|
|
170
|
-
function rerenderInMarket(country: CountryCode): Promise<unknown>[] {
|
|
171
|
-
clearProductCache();
|
|
172
|
-
const jobs: Promise<unknown>[] = [];
|
|
173
|
-
// Only an existing cart moves; switching country never creates one.
|
|
174
|
-
if (Cart.id()) {
|
|
175
|
-
jobs.push(
|
|
176
|
-
Cart.setBuyerIdentity({ countryCode: country }, { create: false }).catch((error: unknown) => {
|
|
177
|
-
reportError(error, 'cart country');
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
pruneLive();
|
|
182
|
-
liveLists.forEach((list) => jobs.push(list.load()));
|
|
183
|
-
liveProducts.forEach((el) => {
|
|
184
|
-
// A product inside a list is re-rendered by its list.
|
|
185
|
-
if (el.closest(listSelector())) return;
|
|
186
|
-
mountedProducts.delete(el);
|
|
187
|
-
mountProduct(el);
|
|
188
|
-
});
|
|
189
|
-
return jobs;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Switches the market: prices, currency and translations follow (`@inContext`), the cart's
|
|
194
|
-
* `buyerIdentity.countryCode` is updated, and every product and list on the page re-renders.
|
|
195
|
-
*
|
|
196
|
-
* Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
|
|
197
|
-
*
|
|
198
|
-
* @returns The cart once everything is in the new market.
|
|
199
|
-
* @throws {TetherError} `INVALID_COUNTRY` for anything that isn't a country code.
|
|
200
|
-
*/
|
|
201
|
-
export async function setCountry(
|
|
202
|
-
code: string,
|
|
203
|
-
{ language, locale, remember = true }: SetCountryOptions = {},
|
|
204
|
-
): Promise<typeof Cart.state> {
|
|
205
|
-
const country = toCountry(code);
|
|
206
|
-
if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
|
|
207
|
-
const entry = localeEntryFor(country);
|
|
208
|
-
const ownLocale = locale || entry?.locale;
|
|
209
|
-
const nextLocale = ownLocale || cfg.locale;
|
|
210
|
-
const nextLanguage = languageFor(language, entry);
|
|
211
|
-
const changed = country !== cfg.country || nextLanguage !== cfg.language || nextLocale !== cfg.locale;
|
|
212
|
-
cfg.country = country;
|
|
213
|
-
cfg.language = nextLanguage || undefined;
|
|
214
|
-
cfg.locale = nextLocale;
|
|
215
|
-
if (remember) rememberMarket({ country, language: nextLanguage, locale: ownLocale });
|
|
216
|
-
if (!changed) {
|
|
217
|
-
void renderCountrySelects();
|
|
218
|
-
return Cart.state;
|
|
219
|
-
}
|
|
220
|
-
// Country names follow the language.
|
|
221
|
-
if (language !== undefined) clearLocalization();
|
|
222
|
-
const jobs = rerenderInMarket(country);
|
|
223
|
-
void renderCountrySelects();
|
|
224
|
-
emit('country-changed', { country, language: cfg.language });
|
|
225
|
-
await Promise.all(jobs);
|
|
226
|
-
return Cart.state;
|
|
227
|
-
}
|
package/src/markets.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
|
-
}
|