fontdue-js 3.6.0 → 3.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 (71) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  3. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  4. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  5. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  6. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  7. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  8. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  9. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  10. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +4 -9
  11. package/dist/__generated__/StoreModalIndexQuery.graphql.js +90 -53
  12. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  13. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  14. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +4 -1
  15. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +12 -5
  16. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.d.ts +2 -1
  17. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +27 -24
  18. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  19. package/dist/__generated__/StoreModalProductQuery.graphql.js +78 -77
  20. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  21. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +54 -53
  22. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +1 -1
  23. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +78 -77
  24. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  25. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  26. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  27. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  28. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  29. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  30. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  31. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  32. package/dist/__tests__/bundleSelectionOrder.test.js +92 -0
  33. package/dist/__tests__/collectionBundleSelection.test.js +441 -1446
  34. package/dist/__tests__/infiniteScroll.test.js +180 -0
  35. package/dist/__tests__/licenseExclusions.test.js +2 -1
  36. package/dist/__tests__/storefrontEvents.test.js +167 -0
  37. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  38. package/dist/components/Cart/Checkout.js +11 -1
  39. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  40. package/dist/components/Cart/storefrontEvents.js +138 -0
  41. package/dist/components/FontFamilies/index.js +4 -3
  42. package/dist/components/Precart/index.js +6 -0
  43. package/dist/components/PriceBar/index.js +5 -3
  44. package/dist/components/Root/productState.d.ts +2 -8
  45. package/dist/components/Root/productState.js +46 -57
  46. package/dist/components/SKUPrice/index.js +7 -8
  47. package/dist/components/StoreModal/StoreModalFamily.js +4 -2
  48. package/dist/components/StoreModal/StoreModalIndex.js +48 -7
  49. package/dist/components/StoreModal/StoreModalProduct.js +16 -9
  50. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  51. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  52. package/dist/components/StoreModalProductLicenseSelection/index.js +3 -2
  53. package/dist/components/TypeTester/useTypeTesterStyler.js +1 -0
  54. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  55. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  56. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  57. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  58. package/dist/components/elements/StoreModalUnifiedCheckout.js +12 -0
  59. package/dist/components/useInfiniteScroll.d.ts +28 -0
  60. package/dist/components/useInfiniteScroll.js +50 -0
  61. package/dist/fontdue.css +33 -0
  62. package/dist/hooks/useAutofit.d.ts +2 -1
  63. package/dist/hooks/useAutofit.js +10 -4
  64. package/dist/reducer.d.ts +5 -5
  65. package/dist/reducer.js +9 -87
  66. package/dist/selection.d.ts +40 -0
  67. package/dist/selection.js +494 -0
  68. package/dist/utils.d.ts +1 -12
  69. package/dist/utils.js +1 -36
  70. package/dist/version.js +1 -1
  71. package/package.json +1 -1
@@ -0,0 +1,494 @@
1
+ /*
2
+ * The Store Modal's selection.
3
+ *
4
+ * What the buyer picks – a style, bundle, family or collection bundle – is kept
5
+ * as picked (`requestedSkuIds`). The SKUs actually selected (`selectedSkuIds`)
6
+ * are derived from the picks: the cheapest combination of known SKUs in which
7
+ * every pick sits inside one SKU granting all of its styles – the pick itself,
8
+ * or something bigger. A selection's price therefore depends only on what's in
9
+ * it, never on the order it was picked in, and overlapping bundles – every pair
10
+ * of weights, each upright with its italic, a few triples – are re-partitioned
11
+ * as it grows (FD-1238).
12
+ *
13
+ * A pick is never split into cheaper parts: a family stays a family (or goes
14
+ * into a collection bundle or superfamily containing it), because buying the
15
+ * family isn't the same purchase as buying today's styles one by one. Picked
16
+ * styles do combine freely, and a combination may grant more than was picked: a
17
+ * family that costs no more than the styles picked from it is selected in their
18
+ * place. Among equally cheap combinations the one granting the most styles
19
+ * wins, then the one with the fewest SKUs – so a variable font comes along with
20
+ * a collection bundle priced like its static family, and a family beats its two
21
+ * halves at the same price.
22
+ */
23
+
24
+ // Nodes the exact search may visit per part of the catalog before it settles
25
+ // for the best combination found so far. Real catalogs finish far below it; it
26
+ // only bounds pathological ones.
27
+ const SEARCH_BUDGET = 20000;
28
+ const MEMO_LIMIT = 10000;
29
+ const EPSILON = 1e-6;
30
+
31
+ // A SKU can be priced once both its styles and its price are known.
32
+ const isPriceable = (catalog, skuId) => {
33
+ var _catalog$skuStyleIds$;
34
+ return (((_catalog$skuStyleIds$ = catalog.skuStyleIds[skuId]) === null || _catalog$skuStyleIds$ === void 0 ? void 0 : _catalog$skuStyleIds$.length) ?? 0) > 0 && typeof catalog.skuPrices[skuId] === 'number';
35
+ };
36
+ const union = (a, b) => [...new Set([...a, ...b])];
37
+ const selectedSkuIdList = state => Object.keys(state.selectedSkuIds).filter(skuId => state.selectedSkuIds[skuId] === true);
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Catalog index
41
+ // ---------------------------------------------------------------------------
42
+
43
+ // Per catalog, worked out once when product data loads:
44
+ //
45
+ // - the styles each priced SKU grants, and which SKUs grant each style;
46
+ // - its bridges: SKUs that hold otherwise separate parts of the catalog
47
+ // together, like a superfamily over its families or a collection bundle over
48
+ // a family and its variable font;
49
+ // - the part each style belongs to once the bridges are set aside.
50
+ //
51
+ // Picks in different parts only interact through bridges, so each part is
52
+ // priced on its own and each bridge is either bought or not. Covers already
53
+ // worked out are kept too: every price on the page asks for the selection plus
54
+ // one SKU, so most of each question has been answered before.
55
+
56
+ const indexCache = new WeakMap();
57
+ const indexFor = _ref => {
58
+ let {
59
+ skuStyleIds,
60
+ skuPrices
61
+ } = _ref;
62
+ const cached = indexCache.get(skuStyleIds);
63
+ if (cached && cached.catalog.skuPrices === skuPrices) return cached;
64
+ const catalog = {
65
+ skuStyleIds,
66
+ skuPrices
67
+ };
68
+ const granted = new Map();
69
+ const skuIdsByStyle = new Map();
70
+ // Two SKUs granting the same styles at the same price are interchangeable, so
71
+ // only the first is offered; picking the other is satisfied by it.
72
+ const offered = new Set();
73
+ for (const skuId of Object.keys(skuStyleIds).sort()) {
74
+ if (!isPriceable(catalog, skuId)) continue;
75
+ const styleIds = new Set(skuStyleIds[skuId]);
76
+ granted.set(skuId, styleIds);
77
+ const identity = `${skuPrices[skuId]}|${[...styleIds].sort().join(',')}`;
78
+ if (offered.has(identity)) continue;
79
+ offered.add(identity);
80
+ for (const styleId of styleIds) {
81
+ skuIdsByStyle.set(styleId, [...(skuIdsByStyle.get(styleId) ?? []), skuId]);
82
+ }
83
+ }
84
+ const offeredSkuIds = new Set([...skuIdsByStyle.values()].flat());
85
+ const offeredGranted = new Map([...granted].filter(_ref2 => {
86
+ let [skuId] = _ref2;
87
+ return offeredSkuIds.has(skuId);
88
+ }));
89
+ const bridges = findBridges(offeredGranted);
90
+ const nonBridges = [...offeredGranted.keys()].filter(skuId => !bridges.includes(skuId));
91
+ const index = {
92
+ catalog,
93
+ granted,
94
+ skuIdsByStyle,
95
+ bridges,
96
+ partOf: connectedStyles(nonBridges, granted),
97
+ containers: new Map(),
98
+ parts: new Map(),
99
+ covers: new Map()
100
+ };
101
+ indexCache.set(skuStyleIds, index);
102
+ return index;
103
+ };
104
+
105
+ // Each style's part: styles connected through the given SKUs share a root.
106
+ const connectedStyles = (skuIds, granted) => {
107
+ const parent = new Map();
108
+ const find = id => {
109
+ if (!parent.has(id)) parent.set(id, id);
110
+ let root = id;
111
+ while (parent.get(root) !== root) root = parent.get(root);
112
+ parent.set(id, root);
113
+ return root;
114
+ };
115
+ for (const skuId of skuIds) {
116
+ const [first, ...rest] = granted.get(skuId);
117
+ find(first);
118
+ for (const styleId of rest) parent.set(find(styleId), find(first));
119
+ }
120
+ return new Map([...parent.keys()].map(styleId => [styleId, find(styleId)]));
121
+ };
122
+
123
+ // A SKU is a bridge when the styles it grants fall into more than one part
124
+ // without it. Widest first, set aside as found, until a pass finds none – so a
125
+ // collection bundle is found once the superfamily above it is set aside.
126
+ const findBridges = granted => {
127
+ const width = skuId => granted.get(skuId).size;
128
+ const byWidth = (a, b) => width(b) - width(a) || (a < b ? -1 : 1);
129
+ const bridges = [];
130
+ let remaining = [...granted.keys()];
131
+ let found = true;
132
+ while (found) {
133
+ found = false;
134
+ for (const skuId of remaining.filter(id => width(id) >= 2).sort(byWidth)) {
135
+ const others = remaining.filter(id => id !== skuId);
136
+ const partOf = connectedStyles(others, granted);
137
+ const parts = new Set([...granted.get(skuId)].map(styleId => partOf.get(styleId) ?? styleId));
138
+ if (parts.size > 1) {
139
+ bridges.push(skuId);
140
+ remaining = others;
141
+ found = true;
142
+ }
143
+ }
144
+ }
145
+ return bridges.sort(byWidth);
146
+ };
147
+
148
+ // The priced SKUs a pick can sit inside: those granting every style it grants.
149
+ const containersOf = (index, pick) => {
150
+ const cached = index.containers.get(pick);
151
+ if (cached) return cached;
152
+ const styleIds = [...index.granted.get(pick)];
153
+ const shortest = styleIds.map(styleId => index.skuIdsByStyle.get(styleId)).reduce((a, b) => b.length < a.length ? b : a);
154
+ const containers = shortest.filter(skuId => {
155
+ const skuStyles = index.granted.get(skuId);
156
+ return styleIds.every(styleId => skuStyles.has(styleId));
157
+ });
158
+ index.containers.set(pick, containers);
159
+ return containers;
160
+ };
161
+
162
+ // The parts a pick's styles belong to.
163
+ const partsOf = (index, pick) => {
164
+ const cached = index.parts.get(pick);
165
+ if (cached) return cached;
166
+ const parts = new Set([...index.granted.get(pick)].map(styleId => index.partOf.get(styleId) ?? styleId));
167
+ index.parts.set(pick, parts);
168
+ return parts;
169
+ };
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Cheapest combination
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const EMPTY_COVER = {
176
+ skuIds: [],
177
+ cost: 0,
178
+ granted: new Set()
179
+ };
180
+ const isBetter = (a, b) => {
181
+ if (Math.abs(a.cost - b.cost) > EPSILON) return a.cost < b.cost;
182
+ if (a.granted.size !== b.granted.size) return a.granted.size > b.granted.size;
183
+ if (a.skuIds.length !== b.skuIds.length) return a.skuIds.length < b.skuIds.length;
184
+ return a.skuIds.join(' ') < b.skuIds.join(' ');
185
+ };
186
+ const combine = covers => ({
187
+ skuIds: covers.flatMap(cover => cover.skuIds).sort(),
188
+ cost: covers.reduce((sum, cover) => sum + cover.cost, 0),
189
+ granted: new Set(covers.flatMap(cover => [...cover.granted]))
190
+ });
191
+ const notNull = value => value !== null;
192
+
193
+ /**
194
+ * The cheapest set of SKUs in which each of `picks` sits inside one SKU
195
+ * granting all of its styles, sorted. Picks whose styles or price aren't known
196
+ * are left out.
197
+ *
198
+ * @example
199
+ * cheapestSkuIds(['sku:bold', 'sku:regular', 'sku:light'], catalog)
200
+ * // => ['sku:light+regular+bold'] rather than a pair plus a single
201
+ */
202
+ export const cheapestSkuIds = (picks, catalog) => cheapestCombination(picks, catalog).skuIds;
203
+ const selectionCost = (picks, catalog) => cheapestCombination(picks, catalog).cost;
204
+ const cheapestCombination = (picks, catalog) => {
205
+ const index = indexFor(catalog);
206
+ const priceable = [...new Set(picks)].filter(pick => index.granted.has(pick)).sort();
207
+ return cover(priceable, new Set(), index) ?? EMPTY_COVER;
208
+ };
209
+
210
+ // The cheapest cover of `picks` (sorted) without the `excluded` bridges, or
211
+ // null when some pick has nowhere to sit without them.
212
+ const cover = (picks, excluded, index) => {
213
+ if (picks.length === 0) return EMPTY_COVER;
214
+ const key = `${picks.join(',')}#${[...excluded].sort().join(',')}`;
215
+ if (index.covers.has(key)) return index.covers.get(key);
216
+ const result = coverUncached(picks, excluded, index);
217
+ if (index.covers.size >= MEMO_LIMIT) index.covers.clear();
218
+ index.covers.set(key, result);
219
+ return result;
220
+ };
221
+ const coverUncached = (picks, excluded, index) => {
222
+ const available = new Map(picks.map(pick => [pick, containersOf(index, pick).filter(id => !excluded.has(id))]));
223
+ if (picks.some(pick => available.get(pick).length === 0)) return null;
224
+ const parts = [...new Set(picks.flatMap(pick => [...partsOf(index, pick)]))];
225
+ if (parts.length === 1) return branchAndBound(picks, available, index);
226
+
227
+ // Parts are joined by a bridge that can hold picks from more than one of
228
+ // them, and by a pick that itself spans several.
229
+ const joining = index.bridges.filter(bridge => !excluded.has(bridge)).map(bridge => ({
230
+ bridge,
231
+ reached: new Set(picks.filter(pick => available.get(pick).includes(bridge)).flatMap(pick => [...partsOf(index, pick)]))
232
+ })).filter(_ref3 => {
233
+ let {
234
+ reached
235
+ } = _ref3;
236
+ return reached.size > 1;
237
+ });
238
+ const groups = joinParts(parts, [...joining.map(_ref4 => {
239
+ let {
240
+ reached
241
+ } = _ref4;
242
+ return reached;
243
+ }), ...picks.map(pick => partsOf(index, pick))]);
244
+ if (groups.length > 1) {
245
+ const groupOfPart = new Map(groups.flatMap((group, i) => group.map(part => [part, i])));
246
+ const picksByGroup = groups.map(() => []);
247
+ for (const pick of picks) {
248
+ const [part] = partsOf(index, pick);
249
+ picksByGroup[groupOfPart.get(part)].push(pick);
250
+ }
251
+ const covers = picksByGroup.map(group => cover(group, excluded, index));
252
+ return covers.every(notNull) ? combine(covers) : null;
253
+ }
254
+
255
+ // One group held together by bridges: buy the widest or don't. (Every
256
+ // container of a pick spanning parts is a bridge, so there is one.)
257
+ const {
258
+ bridge
259
+ } = joining[0];
260
+ const withoutBridge = new Set([...excluded, bridge]);
261
+ const rest = cover(picks.filter(pick => !available.get(pick).includes(bridge)), withoutBridge, index);
262
+ const including = rest && combine([{
263
+ skuIds: [bridge],
264
+ cost: index.catalog.skuPrices[bridge],
265
+ granted: index.granted.get(bridge)
266
+ }, rest]);
267
+ const excluding = cover(picks, withoutBridge, index);
268
+ if (!including || !excluding) return including ?? excluding;
269
+ return isBetter(including, excluding) ? including : excluding;
270
+ };
271
+
272
+ // Parts sharing any of the `links`, transitively, as groups of parts.
273
+ const joinParts = (parts, links) => {
274
+ const parent = new Map(parts.map(part => [part, part]));
275
+ const find = part => {
276
+ let root = part;
277
+ while (parent.get(root) !== root) root = parent.get(root);
278
+ parent.set(part, root);
279
+ return root;
280
+ };
281
+ for (const link of links) {
282
+ const [first, ...rest] = link;
283
+ for (const part of rest) parent.set(find(part), find(first));
284
+ }
285
+ const groups = new Map();
286
+ for (const part of parts) groups.set(find(part), [...(groups.get(find(part)) ?? []), part]);
287
+ return [...groups.values()];
288
+ };
289
+ const countBits = mask => {
290
+ let count = 0;
291
+ for (let word of mask) {
292
+ word -= word >>> 1 & 0x55555555;
293
+ word = (word & 0x33333333) + (word >>> 2 & 0x33333333);
294
+ count += Math.imul(word + (word >>> 4) & 0x0f0f0f0f, 0x01010101) >>> 24;
295
+ }
296
+ return count;
297
+ };
298
+ const countShared = (a, b) => countBits(a.map((word, i) => word & b[i]));
299
+ const hasBit = (mask, bit) => (mask[bit >>> 5] & 1 << (bit & 31)) !== 0;
300
+ const setBit = (mask, bit) => {
301
+ mask[bit >>> 5] |= 1 << (bit & 31);
302
+ };
303
+
304
+ // Exact minimum-cost cover of the picks in one part, by branch and bound:
305
+ // branch on the unplaced pick with the fewest options, and prune a branch once
306
+ // its cost plus a lower bound on the rest (each unplaced pick at its cheapest
307
+ // per-pick share) can't beat the best cover found. Ties aren't pruned, so the
308
+ // tie-breaks see every cover at the best price.
309
+ const branchAndBound = (picks, available, index) => {
310
+ const words = Math.ceil(picks.length / 32);
311
+ const masks = new Map();
312
+ picks.forEach((pick, bit) => {
313
+ for (const skuId of available.get(pick)) {
314
+ if (!masks.has(skuId)) masks.set(skuId, new Uint32Array(words));
315
+ setBit(masks.get(skuId), bit);
316
+ }
317
+ });
318
+ const options = [...masks].map(_ref5 => {
319
+ let [skuId, mask] = _ref5;
320
+ return {
321
+ skuId,
322
+ price: index.catalog.skuPrices[skuId],
323
+ mask,
324
+ holds: countBits(mask),
325
+ granted: index.granted.get(skuId)
326
+ };
327
+ }).sort((a, b) => a.price / a.holds - b.price / b.holds || b.granted.size - a.granted.size || (a.skuId < b.skuId ? -1 : 1));
328
+ const optionsByPick = picks.map((_, bit) => options.flatMap((option, i) => hasBit(option.mask, bit) ? [i] : []));
329
+ const toCover = (chosen, cost) => ({
330
+ skuIds: chosen.map(i => options[i].skuId).sort(),
331
+ cost,
332
+ granted: new Set(chosen.flatMap(i => [...options[i].granted]))
333
+ });
334
+ const all = new Uint32Array(words);
335
+ picks.forEach((_, bit) => setBit(all, bit));
336
+ let best = toCover(...greedyCover(options, all));
337
+ let budget = SEARCH_BUDGET;
338
+ const chosen = [];
339
+ const search = (unplaced, cost) => {
340
+ if (budget-- <= 0 || cost > best.cost + EPSILON) return;
341
+ let branchOn = -1;
342
+ let lowerBound = 0;
343
+ for (let bit = 0; bit < picks.length; bit++) {
344
+ if (!hasBit(unplaced, bit)) continue;
345
+ const pickOptions = optionsByPick[bit];
346
+ if (branchOn < 0 || pickOptions.length < optionsByPick[branchOn].length) branchOn = bit;
347
+ let cheapestShare = Infinity;
348
+ for (const i of pickOptions) {
349
+ cheapestShare = Math.min(cheapestShare, options[i].price / countShared(options[i].mask, unplaced));
350
+ }
351
+ lowerBound += cheapestShare;
352
+ }
353
+ if (branchOn < 0) {
354
+ const candidate = toCover(chosen, cost);
355
+ if (isBetter(candidate, best)) best = candidate;
356
+ return;
357
+ }
358
+ if (cost + lowerBound > best.cost + EPSILON) return;
359
+ for (const i of optionsByPick[branchOn]) {
360
+ chosen.push(i);
361
+ search(unplaced.map((word, w) => word & ~options[i].mask[w]), cost + options[i].price);
362
+ chosen.pop();
363
+ }
364
+ };
365
+ search(all, 0);
366
+ return best;
367
+ };
368
+
369
+ // A starting cover to prune against: repeatedly take the option with the lowest
370
+ // price per pick it newly holds.
371
+ const greedyCover = (options, all) => {
372
+ const unplaced = all.slice();
373
+ const chosen = [];
374
+ let cost = 0;
375
+ while (countBits(unplaced) > 0) {
376
+ let pick = -1;
377
+ let pickShare = Infinity;
378
+ options.forEach((option, i) => {
379
+ const holds = countShared(option.mask, unplaced);
380
+ if (holds > 0 && option.price / holds < pickShare - EPSILON) {
381
+ pick = i;
382
+ pickShare = option.price / holds;
383
+ }
384
+ });
385
+ chosen.push(pick);
386
+ cost += options[pick].price;
387
+ options[pick].mask.forEach((word, w) => unplaced[w] &= ~word);
388
+ }
389
+ return [chosen, cost];
390
+ };
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Selection state
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Re-derives `selectedSkuIds` from the picks. A pick whose styles or price
398
+ * aren't known yet – selected from the host page before its product data
399
+ * arrived – is bought as it is until they are.
400
+ */
401
+ export const repriceSelection = state => {
402
+ const unpriceable = state.requestedSkuIds.filter(skuId => !isPriceable(state, skuId));
403
+ const skuIds = union(cheapestSkuIds(state.requestedSkuIds, state), unpriceable);
404
+ const unchanged = selectedSkuIdList(state).length === skuIds.length && skuIds.every(skuId => state.selectedSkuIds[skuId]);
405
+ return unchanged ? state : {
406
+ ...state,
407
+ selectedSkuIds: Object.fromEntries(skuIds.map(skuId => [skuId, true]))
408
+ };
409
+ };
410
+
411
+ /**
412
+ * Adds a pick, or takes out every pick sharing a style with the clicked SKU.
413
+ *
414
+ * If what's left is still cheapest bought with a SKU granting the clicked one's
415
+ * styles anyway – it's part of a bundle that costs no more without it – the
416
+ * click would change nothing, so the picks inside the selected SKUs granting
417
+ * them go too, as deselecting a member of a selected bundle always has.
418
+ */
419
+ export const selectSku = (state, skuId, selected) => {
420
+ if (selected) {
421
+ return repriceSelection({
422
+ ...state,
423
+ requestedSkuIds: union(state.requestedSkuIds, [skuId])
424
+ });
425
+ }
426
+ const sharesStyleWith = styleIds => id => id === skuId || (state.skuStyleIds[id] ?? []).some(styleId => styleIds.includes(styleId));
427
+ const styleIds = state.skuStyleIds[skuId] ?? [];
428
+ const next = repriceSelection({
429
+ ...state,
430
+ requestedSkuIds: state.requestedSkuIds.filter(id => !sharesStyleWith(styleIds)(id))
431
+ });
432
+ if (styleIds.length === 0 || !isSelected(skuId)(next)) return next;
433
+ const holding = selectedSkuIdList(state).filter(sharesStyleWith(styleIds));
434
+ const heldStyleIds = [...styleIds, ...holding.flatMap(id => state.skuStyleIds[id] ?? [])];
435
+ return repriceSelection({
436
+ ...state,
437
+ requestedSkuIds: state.requestedSkuIds.filter(id => !sharesStyleWith(heldStyleIds)(id))
438
+ });
439
+ };
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Reading the selection
443
+ // ---------------------------------------------------------------------------
444
+
445
+ const grantedCache = new WeakMap();
446
+ const grantedStyleIds = state => {
447
+ const cached = grantedCache.get(state.selectedSkuIds);
448
+ if (cached && cached.skuStyleIds === state.skuStyleIds) return cached.styleIds;
449
+ const styleIds = new Set(selectedSkuIdList(state).flatMap(skuId => state.skuStyleIds[skuId] ?? []));
450
+ grantedCache.set(state.selectedSkuIds, {
451
+ skuStyleIds: state.skuStyleIds,
452
+ styleIds
453
+ });
454
+ return styleIds;
455
+ };
456
+
457
+ /**
458
+ * Whether a SKU – or a style with no SKU of its own – is in the selection:
459
+ * selected itself, or every style it grants is granted by what is selected.
460
+ */
461
+ export const isSelected = skuIdOrStyleId => state => {
462
+ if (!skuIdOrStyleId) return false;
463
+ if (state.selectedSkuIds[skuIdOrStyleId] === true) return true;
464
+ const granted = grantedStyleIds(state);
465
+ if (granted.has(skuIdOrStyleId)) return true;
466
+ const styleIds = state.skuStyleIds[skuIdOrStyleId];
467
+ return !!styleIds && styleIds.length > 0 && styleIds.every(styleId => granted.has(styleId));
468
+ };
469
+ const costCache = new WeakMap();
470
+
471
+ /**
472
+ * What picking a SKU would add to the selection's price: the cheapest
473
+ * combination with it, less the current one. Never more than the SKU's own
474
+ * price, and less when it completes a cheaper bundle – the Store Modal's
475
+ * “+$X”. Null when the SKU's styles or price aren't known.
476
+ */
477
+ export const additionalCost = (state, skuId) => {
478
+ if (!isPriceable(state, skuId)) return null;
479
+ let cache = costCache.get(state.requestedSkuIds);
480
+ if (!cache || cache.skuStyleIds !== state.skuStyleIds || cache.skuPrices !== state.skuPrices) {
481
+ cache = {
482
+ skuStyleIds: state.skuStyleIds,
483
+ skuPrices: state.skuPrices,
484
+ base: selectionCost(state.requestedSkuIds, state),
485
+ costs: new Map()
486
+ };
487
+ costCache.set(state.requestedSkuIds, cache);
488
+ }
489
+ if (!cache.costs.has(skuId)) {
490
+ const withSku = selectionCost(union(state.requestedSkuIds, [skuId]), state);
491
+ cache.costs.set(skuId, withSku - cache.base);
492
+ }
493
+ return cache.costs.get(skuId);
494
+ };
package/dist/utils.d.ts CHANGED
@@ -1,17 +1,7 @@
1
- import { CollectionStyleSkus } from './components/Root/productState.js';
2
- import type { FontdueState } from './reducer.js';
1
+ export { isSelected } from './selection.js';
3
2
  export declare const pluralize: (count: number, singular: string, plural?: string) => string;
4
3
  export declare function notEmpty<TValue>(value: TValue | null | undefined): value is TValue;
5
4
  export declare function kebabToCamel(str: string): string;
6
- export declare const collContainsSkuId: (coll: CollectionStyleSkus | undefined, skuIdOrStyleId: string) => boolean;
7
- export declare const collTransitivelyContainsSkuId: (collectionStyleSkus: {
8
- [key: string]: CollectionStyleSkus | undefined;
9
- }, collSkuId: string, skuIdOrStyleId: string) => boolean;
10
- interface CollectionSkuIdsDifferences {
11
- [collectionSkuId: string]: number;
12
- }
13
- export declare const isSelected: (skuIdOrStyleId: string | null) => (state: FontdueState) => boolean;
14
- export declare const collectionSkuIdsDifferences: (state: FontdueState, skuId: string) => CollectionSkuIdsDifferences;
15
5
  export type VariableSettings = {
16
6
  [axis: string]: number;
17
7
  };
@@ -31,4 +21,3 @@ export declare const parseCustomProperties: (props: readonly string[] | null | u
31
21
  };
32
22
  export declare const inspect: (value: any) => any;
33
23
  export declare function getFeatureGlyphs(supportedLanguages: readonly string[] | null): "Aa" | "ت" | "Αα" | "Аа" | null;
34
- export {};
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { isSelected } from './selection.js';
1
2
  export const pluralize = (count, singular, plural) => {
2
3
  if (count === 1) return `1 ${singular}`;
3
4
  if (!plural) return `${count} ${singular}s`;
@@ -11,42 +12,6 @@ export function kebabToCamel(str) {
11
12
  return g[1].toUpperCase();
12
13
  });
13
14
  }
14
-
15
- // where coll is a member of `collectionStyleSkus`;
16
- export const collContainsSkuId = (coll, skuIdOrStyleId) => {
17
- if (!coll) return false;
18
- return coll.childrenSkuIds.indexOf(skuIdOrStyleId) >= 0 || coll.fontStyleSkuIds.indexOf(skuIdOrStyleId) >= 0 || coll.fontStyleIds.indexOf(skuIdOrStyleId) >= 0;
19
- };
20
-
21
- // Transitive containment check — walks the collection hierarchy to find
22
- // if a SKU is contained at any depth (e.g., collection bundle → family → bundle)
23
- export const collTransitivelyContainsSkuId = (collectionStyleSkus, collSkuId, skuIdOrStyleId) => {
24
- const coll = collectionStyleSkus[collSkuId];
25
- if (!coll) return false;
26
- if (collContainsSkuId(coll, skuIdOrStyleId)) return true;
27
- return coll.childrenSkuIds.some(childSkuId => collTransitivelyContainsSkuId(collectionStyleSkus, childSkuId, skuIdOrStyleId));
28
- };
29
- export const isSelected = skuIdOrStyleId => state => {
30
- if (!skuIdOrStyleId) return false;
31
- if (state.selectedSkuIds[skuIdOrStyleId] === true) return true;
32
- // get the collections that contain this skuId as a fontStyle or child
33
- const parentCollectionSkuIds = Object.keys(state.collectionStyleSkus).filter(collSkuId => collContainsSkuId(state.collectionStyleSkus[collSkuId], skuIdOrStyleId));
34
- // Recursively check ancestors — handles transitive selection
35
- // (e.g., collection bundle → family → bundle)
36
- return parentCollectionSkuIds.some(parentSkuId => isSelected(parentSkuId)(state));
37
- };
38
-
39
- // find any collection ancestors that contain this skuId,
40
- // then for each collection, sum the price of selected skus,
41
- // and return the delta between the collection price and the sum.
42
- export const collectionSkuIdsDifferences = (state, skuId) => Object.keys(state.collectionStyleSkus).filter(collectionSkuId => {
43
- return skuId === collectionSkuId || collTransitivelyContainsSkuId(state.collectionStyleSkus, collectionSkuId, skuId);
44
- }).reduce((acc, collectionSkuId) => {
45
- const selectedSkuIdsInColl = Object.keys(state.selectedSkuIds).filter(selectedSkuId => state.selectedSkuIds[selectedSkuId] === true && collTransitivelyContainsSkuId(state.collectionStyleSkus, collectionSkuId, selectedSkuId));
46
- const sumOfSelectedSkuIdsInColl = selectedSkuIdsInColl.map(selectedSkuId => state.skuPrices[selectedSkuId] ?? 0).reduce((acc, price) => acc + price, 0);
47
- acc[collectionSkuId] = (state.skuPrices[collectionSkuId] ?? 0) - sumOfSelectedSkuIdsInColl;
48
- return acc;
49
- }, {});
50
15
  export const variableInstanceSettings = instance => instance === null || instance === void 0 ? void 0 : instance.coordinates.reduce((acc, _ref) => {
51
16
  let {
52
17
  axis,
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  // (Fontage.ClientVersions) to spot sites that need to upgrade before a
9
9
  // feature that depends on a newer client can work for them.
10
10
  export const CLIENT_VERSION_HEADER = 'fontdue-client-version';
11
- export const version = "3.6.0";
11
+ export const version = "3.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",