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
@@ -40,6 +40,7 @@ const useTypeTesterStyler = _ref => {
40
40
  fontWeight: fontStyles.fontWeight,
41
41
  fontStyle: fontStyles.fontStyle,
42
42
  fontVariationSettings,
43
+ fontFeatureSettings,
43
44
  enabled: autofit && (!contentEdited || autofitOnChange),
44
45
  padding: sideBuffer
45
46
  });
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  interface StoreModalContainerContextProps {
3
3
  scrollToTop?: () => void;
4
+ scrollContainer?: React.RefObject<HTMLDivElement | null>;
4
5
  }
5
6
  declare const _default: React.Context<StoreModalContainerContextProps>;
6
7
  export default _default;
@@ -1,4 +1,4 @@
1
- import React, { useRef, useCallback } from 'react';
1
+ import React, { useRef, useCallback, useMemo } from 'react';
2
2
  import { ArrowLeft, CartNew, X } from '../../Icons/index.js';
3
3
  import StoreModalContainerContext from './StoreModalContainerContext.js';
4
4
  const StoreModalContainer = _ref => {
@@ -25,10 +25,15 @@ const StoreModalContainer = _ref => {
25
25
  left: 0
26
26
  });
27
27
  }, [container]);
28
+
29
+ // Stable, so an effect that depends on the context value (the index's
30
+ // load-more observer) isn't rebuilt on every render of the modal.
31
+ const context = useMemo(() => ({
32
+ scrollToTop: handleScrollToTop,
33
+ scrollContainer: container
34
+ }), [handleScrollToTop]);
28
35
  return /*#__PURE__*/React.createElement(StoreModalContainerContext.Provider, {
29
- value: {
30
- scrollToTop: handleScrollToTop
31
- }
36
+ value: context
32
37
  }, /*#__PURE__*/React.createElement("div", {
33
38
  className: "store-modal__container__overlay",
34
39
  "data-route": routeName
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface StoreModalIndexLoadMoreProps {
3
+ sentinelRef: (node: Element | null) => void;
4
+ onClick: () => void;
5
+ loading: boolean;
6
+ }
7
+ declare const StoreModalIndexLoadMore: React.FunctionComponent<StoreModalIndexLoadMoreProps>;
8
+ export default StoreModalIndexLoadMore;
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ // Sits after the last collection while more remain. The button is what a
3
+ // visitor sees while a page is on its way, and the way to go on without
4
+ // scrolling — with a keyboard, or if the observer never fires.
5
+ const StoreModalIndexLoadMore = _ref => {
6
+ let {
7
+ sentinelRef,
8
+ onClick,
9
+ loading
10
+ } = _ref;
11
+ return /*#__PURE__*/React.createElement("div", {
12
+ className: "store-modal__index__load-more",
13
+ ref: sentinelRef
14
+ }, /*#__PURE__*/React.createElement("button", {
15
+ type: "button",
16
+ className: "store-modal__index__load-more__button",
17
+ onClick: onClick,
18
+ disabled: loading
19
+ }, loading ? 'Loading…' : 'Load more'));
20
+ };
21
+ export default StoreModalIndexLoadMore;
@@ -79,6 +79,7 @@ import { Check, X } from '../Icons/index.js';
79
79
  import ConfigContext from '../ConfigContext.js';
80
80
  import { Price } from '../Price/index.js';
81
81
  import { sendOrderTracking } from '../Cart/orderTracking.js';
82
+ import { trackPaymentInfoEntered } from '../Cart/storefrontEvents.js';
82
83
  import { validateAddressElements, validateElements } from '../../stripeElements.js';
83
84
  import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
84
85
 
@@ -287,6 +288,16 @@ export default function StoreModalUnifiedCheckout(_ref2) {
287
288
  useEffect(() => {
288
289
  sendOrderTracking(environment);
289
290
  }, [environment]);
291
+
292
+ // The Payment Element reports every keystroke; the first time the details
293
+ // are complete is the "Payment Info Entered" moment. The server keeps it to
294
+ // one per order, so this only saves requests.
295
+ const paymentInfoTracked = useRef(false);
296
+ const handlePaymentChange = useCallback(event => {
297
+ if (!event.complete || paymentInfoTracked.current) return;
298
+ paymentInfoTracked.current = true;
299
+ trackPaymentInfoEntered(environment);
300
+ }, [environment]);
290
301
  const [error, setError] = useState(null);
291
302
  const [errorsObject, setErrorsObject] = useState(null);
292
303
  const [customerErrorsObject, setCustomerErrorsObject] = useState(null);
@@ -789,6 +800,7 @@ export default function StoreModalUnifiedCheckout(_ref2) {
789
800
  }, /*#__PURE__*/React.createElement(Sentry.ErrorBoundary, {
790
801
  fallback: /*#__PURE__*/React.createElement("div", null, "Something went wrong")
791
802
  }, /*#__PURE__*/React.createElement(PaymentElement, {
803
+ onChange: handlePaymentChange,
792
804
  options: {
793
805
  layout: {
794
806
  type: 'tabs',
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+ interface UseInfiniteScrollOptions {
3
+ enabled: boolean;
4
+ onLoadMore: () => void;
5
+ rootRef?: React.RefObject<Element | null>;
6
+ rootMargin?: string;
7
+ }
8
+ /**
9
+ * Calls `onLoadMore` when a sentinel element scrolls into view, for lists that
10
+ * page as the visitor reaches the end of them.
11
+ *
12
+ * Returns a ref callback to put on that sentinel — render it after the last
13
+ * item, and only while there is more to load:
14
+ *
15
+ * const sentinelRef = useInfiniteScroll({
16
+ * enabled: hasNext && !isLoadingNext,
17
+ * onLoadMore: () => loadNext(25),
18
+ * rootRef: scrollContainer,
19
+ * });
20
+ *
21
+ * {hasNext && <div ref={sentinelRef} />}
22
+ *
23
+ * The sentinel is tracked as state rather than a ref so that mounting and
24
+ * unmounting it re-runs the effect; `onLoadMore` is read through a ref, so a
25
+ * fresh callback on every render doesn't tear the observer down and rebuild it.
26
+ */
27
+ declare const useInfiniteScroll: ({ enabled, onLoadMore, rootRef, rootMargin, }: UseInfiniteScrollOptions) => ((node: Element | null) => void);
28
+ export default useInfiniteScroll;
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ /**
5
+ * Calls `onLoadMore` when a sentinel element scrolls into view, for lists that
6
+ * page as the visitor reaches the end of them.
7
+ *
8
+ * Returns a ref callback to put on that sentinel — render it after the last
9
+ * item, and only while there is more to load:
10
+ *
11
+ * const sentinelRef = useInfiniteScroll({
12
+ * enabled: hasNext && !isLoadingNext,
13
+ * onLoadMore: () => loadNext(25),
14
+ * rootRef: scrollContainer,
15
+ * });
16
+ *
17
+ * {hasNext && <div ref={sentinelRef} />}
18
+ *
19
+ * The sentinel is tracked as state rather than a ref so that mounting and
20
+ * unmounting it re-runs the effect; `onLoadMore` is read through a ref, so a
21
+ * fresh callback on every render doesn't tear the observer down and rebuild it.
22
+ */
23
+ const useInfiniteScroll = _ref => {
24
+ let {
25
+ enabled,
26
+ onLoadMore,
27
+ rootRef,
28
+ rootMargin
29
+ } = _ref;
30
+ const [sentinel, setSentinel] = useState(null);
31
+ const savedOnLoadMore = useRef(onLoadMore);
32
+ savedOnLoadMore.current = onLoadMore;
33
+ useEffect(() => {
34
+ if (!sentinel || !enabled) return;
35
+ // Absent while server-rendering, and in test environments without one.
36
+ if (typeof IntersectionObserver === 'undefined') return;
37
+ const observer = new IntersectionObserver(entries => {
38
+ if (entries.some(entry => entry.isIntersecting)) {
39
+ savedOnLoadMore.current();
40
+ }
41
+ }, {
42
+ root: (rootRef === null || rootRef === void 0 ? void 0 : rootRef.current) ?? null,
43
+ rootMargin
44
+ });
45
+ observer.observe(sentinel);
46
+ return () => observer.disconnect();
47
+ }, [sentinel, enabled, rootRef, rootMargin]);
48
+ return setSentinel;
49
+ };
50
+ export default useInfiniteScroll;
package/dist/fontdue.css CHANGED
@@ -2038,6 +2038,39 @@ input:checked + .checkbox__icon {
2038
2038
  font-size: 20px;
2039
2039
  }
2040
2040
 
2041
+ .store-modal__index__load-more {
2042
+ grid-column: 1/-1;
2043
+ padding-bottom: 20px;
2044
+ text-align: center;
2045
+ }
2046
+
2047
+ .store-modal__index__load-more__button {
2048
+ background: none;
2049
+ color: inherit;
2050
+ font: inherit;
2051
+ text-decoration: inherit;
2052
+ text-align: inherit;
2053
+ border: 0;
2054
+ border-radius: 0;
2055
+ padding: 0;
2056
+ margin: 0;
2057
+ appearance: none;
2058
+ text-transform: inherit;
2059
+ }
2060
+ .store-modal__index__load-more__button:active, .store-modal__index__load-more__button:focus {
2061
+ outline: none;
2062
+ }
2063
+ .store-modal__index__load-more__button:not(:disabled) {
2064
+ cursor: pointer;
2065
+ }
2066
+ .store-modal__index__load-more__button {
2067
+ color: var(--secondary_text_color);
2068
+ text-decoration: underline;
2069
+ }
2070
+ .store-modal__index__load-more__button:disabled {
2071
+ text-decoration: none;
2072
+ }
2073
+
2041
2074
  .store-modal__page__body:has(> .store-modal__index-item__button) {
2042
2075
  display: grid;
2043
2076
  grid-template-columns: auto 1fr auto;
@@ -8,6 +8,7 @@ export interface UseAutofitProps {
8
8
  fontWeight?: React.CSSProperties['fontWeight'];
9
9
  fontStyle?: string;
10
10
  fontVariationSettings?: string;
11
+ fontFeatureSettings?: string;
11
12
  enabled?: boolean;
12
13
  padding?: number;
13
14
  }
@@ -16,5 +17,5 @@ export interface UseAutofitResult {
16
17
  fontSize: number;
17
18
  ready: boolean;
18
19
  }
19
- declare const useAutofit: ({ text, fontFamily, fontSize: externalFontSize, min, max, letterSpacing, fontWeight, fontStyle, fontVariationSettings, enabled, padding, }: UseAutofitProps) => UseAutofitResult;
20
+ declare const useAutofit: ({ text, fontFamily, fontSize: externalFontSize, min, max, letterSpacing, fontWeight, fontStyle, fontVariationSettings, fontFeatureSettings, enabled, padding, }: UseAutofitProps) => UseAutofitResult;
20
21
  export default useAutofit;
@@ -9,7 +9,8 @@ function getCanvasContext() {
9
9
  return sharedCanvas.getContext('2d');
10
10
  }
11
11
 
12
- // DOM measurement (fallback for variable fonts)
12
+ // DOM measurement (fallback for variable fonts and OpenType features, which
13
+ // canvas can't express: `ctx.font` takes neither variation nor feature settings)
13
14
  let measureEl = null;
14
15
  function getMeasureElement() {
15
16
  if (!measureEl) {
@@ -31,7 +32,7 @@ function measureWithCanvas(text, fontFamily, fontWeight, fontStyleValue, letterS
31
32
  ctx.letterSpacing = '0px';
32
33
  return width;
33
34
  }
34
- function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpacing, fontVariationSettings) {
35
+ function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpacing, fontVariationSettings, fontFeatureSettings) {
35
36
  const el = getMeasureElement();
36
37
  el.style.fontFamily = fontFamily;
37
38
  el.style.fontWeight = `${fontWeight}`;
@@ -39,6 +40,7 @@ function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpac
39
40
  el.style.fontSize = `${REF_SIZE}px`;
40
41
  el.style.letterSpacing = `${letterSpacing}em`;
41
42
  el.style.fontVariationSettings = fontVariationSettings;
43
+ el.style.fontFeatureSettings = fontFeatureSettings;
42
44
  el.textContent = text;
43
45
  return el.getBoundingClientRect().width;
44
46
  }
@@ -53,6 +55,7 @@ const useAutofit = _ref => {
53
55
  fontWeight = 400,
54
56
  fontStyle = 'normal',
55
57
  fontVariationSettings,
58
+ fontFeatureSettings,
56
59
  enabled = true,
57
60
  padding = 0
58
61
  } = _ref;
@@ -83,7 +86,10 @@ const useAutofit = _ref => {
83
86
  // Split by newlines and measure the widest line, since the rendered
84
87
  // text preserves line breaks (Draft.js uses white-space: pre-wrap).
85
88
  const lines = text.split('\n');
86
- const measure = fontVariationSettings ? line => measureWithDOM(line, fontFamily, fontWeight, fontStyle, letterSpacing, fontVariationSettings) : line => measureWithCanvas(line, fontFamily, fontWeight, fontStyle, letterSpacing);
89
+ // Enabled features can change glyph widths (stylistic sets, small caps,
90
+ // ligatures), so the text has to be measured with them applied. The
91
+ // measure element is shared, so both settings are written on every call.
92
+ const measure = fontVariationSettings || fontFeatureSettings ? line => measureWithDOM(line, fontFamily, fontWeight, fontStyle, letterSpacing, fontVariationSettings || 'normal', fontFeatureSettings || 'normal') : line => measureWithCanvas(line, fontFamily, fontWeight, fontStyle, letterSpacing);
87
93
  const refWidth = Math.max(...lines.map(measure));
88
94
  if (refWidth <= 0) return;
89
95
 
@@ -92,7 +98,7 @@ const useAutofit = _ref => {
92
98
  const newSize = Math.min(Math.max(min, Math.floor(availableWidth / (refWidth / REF_SIZE))), max);
93
99
  setFontSize(newSize);
94
100
  if (!measured) setMeasured(true);
95
- }, [text, fontFamily, fontWeight, fontStyle, fontVariationSettings, letterSpacing, containerWidth, enabled, min, max, padding, fontVersion]);
101
+ }, [text, fontFamily, fontWeight, fontStyle, fontVariationSettings, fontFeatureSettings, letterSpacing, containerWidth, enabled, min, max, padding, fontVersion]);
96
102
  if (!enabled) return {
97
103
  ref,
98
104
  fontSize: externalFontSize,
package/dist/reducer.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { CollectionStyleSkusMap } from './components/Root/productState.js';
1
+ import { SkuStyleIds } from './components/Root/productState.js';
2
2
  import { Reducer } from 'redux';
3
3
  import { productState_Query$data } from './__generated__/productState_Query.graphql.js';
4
4
  import type { RouteConfig } from './components/StoreModal/routes.js';
5
- import { collectionSkuIdsDifferences, isSelected } from './utils.js';
5
+ import { isSelected } from './selection.js';
6
6
  import { Config } from './components/ConfigContext.js';
7
- export { collectionSkuIdsDifferences, isSelected };
7
+ export { isSelected };
8
8
  interface LicenseOptionSpec {
9
9
  licenseId: string;
10
10
  licenseOptions: {
@@ -22,8 +22,9 @@ export interface FontdueState {
22
22
  selectedSkuIds: {
23
23
  [skuId: string]: boolean;
24
24
  };
25
+ requestedSkuIds: string[];
25
26
  licenseOptions: LicenseOptionSpec[];
26
- collectionStyleSkus: CollectionStyleSkusMap;
27
+ skuStyleIds: SkuStyleIds;
27
28
  collectionSkus: {
28
29
  [collectionSkuId: string]: string | null;
29
30
  };
@@ -94,7 +95,6 @@ export type FontdueAction = {
94
95
  type: 'UPDATE_LICENSEE_IS_BILLING_IDENTITY';
95
96
  value: boolean;
96
97
  };
97
- export declare const collectionSkuIdWithDiscount: (state: FontdueState, skuId: string) => [string, number] | [null, null];
98
98
  declare const reducer: Reducer<FontdueState, FontdueAction>;
99
99
  export declare function createDefaultStore(config: Config | undefined): import("redux").Store<FontdueState, FontdueAction, unknown>;
100
100
  export default reducer;
package/dist/reducer.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { populateProductStateReducer } from './components/Root/productState.js';
2
2
  import { createStore } from 'redux';
3
3
  import { shallowEqual } from 'react-redux';
4
- import { collectionSkuIdsDifferences, collTransitivelyContainsSkuId, isSelected } from './utils.js';
4
+ import { isSelected, repriceSelection, selectSku } from './selection.js';
5
5
  import { makeConfig } from './components/ConfigContext.js';
6
- export { collectionSkuIdsDifferences, isSelected };
6
+ export { isSelected };
7
7
  const unselectLicense = (state, action) => {
8
8
  if (!(action.type === 'PRECART_UNSELECT_LICENSE' || action.type === 'PRECART_TOGGLE_LICENSE')) return state;
9
9
  return {
@@ -61,96 +61,17 @@ const selectLicenseOption = (state, action) => {
61
61
  })
62
62
  };
63
63
  };
64
- export const collectionSkuIdWithDiscount = (state, skuId) => {
65
- const differences = collectionSkuIdsDifferences(state, skuId);
66
- const collectionId = Object.keys(differences).filter(collSkuId => differences[collSkuId] <= state.skuPrices[skuId]).reduce((smallest, key) => {
67
- if (!smallest || differences[key] < differences[smallest]) return key;
68
- if (differences[key] === differences[smallest]) {
69
- // When differences are equal, prefer a collection that is not the
70
- // SKU being selected — this ensures collection bundles are preferred
71
- // over selecting the family itself at the same price
72
- if (smallest === skuId && key !== skuId) return key;
73
- // Among non-self candidates, prefer the one covering more unique
74
- // font styles — this ensures the most comprehensive collection wins
75
- if (smallest !== skuId && key !== skuId) {
76
- var _state$collectionStyl, _state$collectionStyl2;
77
- const smallestStyles = new Set((_state$collectionStyl = state.collectionStyleSkus[smallest]) === null || _state$collectionStyl === void 0 ? void 0 : _state$collectionStyl.fontStyleSkuIds).size;
78
- const keyStyles = new Set((_state$collectionStyl2 = state.collectionStyleSkus[key]) === null || _state$collectionStyl2 === void 0 ? void 0 : _state$collectionStyl2.fontStyleSkuIds).size;
79
- if (keyStyles > smallestStyles) return key;
80
- }
81
- }
82
- return smallest;
83
- }, null);
84
- if (collectionId) return [collectionId, differences[collectionId]];
85
- return [null, null];
86
- };
87
- const selectedCollectionSkuIdContainingSkuId = (state, skuId) => Object.keys(state.selectedSkuIds).filter(id => state.selectedSkuIds[id] === true).find(selectedSkuId => collTransitivelyContainsSkuId(state.collectionStyleSkus, selectedSkuId, skuId));
88
- const setKeysFalse = (acc, key) => {
89
- acc[key] = false;
90
- return acc;
91
- };
92
- const unselectedMembersSkuIds = (state, action) => {
93
- if (action.type !== 'SELECT_SKU_ID') throw new Error('Wrong action ${action.type}');
94
- const {
95
- skuId,
96
- selected
97
- } = action;
98
- if (!selected) return {}; // only do this in case we are selecting something
99
-
100
- // Recursively collect all descendant SKU IDs so that transitive
101
- // selections (e.g., collection bundle → family → bundle) are all
102
- // marked as false
103
- const result = {};
104
- const collectMembers = collSkuId => {
105
- const members = state.collectionStyleSkus[collSkuId];
106
- if (!members) return;
107
- for (const id of members.fontStyleSkuIds) {
108
- result[id] = false;
109
- }
110
- for (const id of members.childrenSkuIds) {
111
- result[id] = false;
112
- collectMembers(id);
113
- }
114
- };
115
- collectMembers(skuId);
116
- return result;
117
- };
118
64
  const selectSkuId = (state, action) => {
119
65
  if (action.type !== 'SELECT_SKU_ID') return state;
120
- const {
121
- skuId,
122
- selected
123
- } = action;
124
- let actualSkuId = skuId;
125
- if (selected) {
126
- // check if selecting any collections instead of this sku
127
- // would reduce the selection price
128
- const [collectionSkuId, _amount] = collectionSkuIdWithDiscount(state, skuId);
129
- actualSkuId = collectionSkuId || actualSkuId;
130
- } else if (state.selectedSkuIds[skuId] === false) {
131
- // we're clicking "deselect" on an item that's not actually selected,
132
- // it's a member of a collection which is selected. in this case
133
- // we'll find the collection and deselect that collection instead
134
- const parentId = selectedCollectionSkuIdContainingSkuId(state, skuId);
135
- if (parentId) actualSkuId = parentId;
136
- }
137
66
  return {
138
- ...state,
139
- precartOpen: true,
140
- selectedSkuIds: {
141
- ...state.selectedSkuIds,
142
- [actualSkuId]: selected,
143
- ...unselectedMembersSkuIds(state, {
144
- ...action,
145
- skuId: actualSkuId,
146
- selected
147
- })
148
- }
67
+ ...selectSku(state, action.skuId, action.selected),
68
+ precartOpen: true
149
69
  };
150
70
  };
151
71
  const unselectSkus = state => ({
152
72
  ...state,
153
- selectedSkuIds: {}
73
+ selectedSkuIds: {},
74
+ requestedSkuIds: []
154
75
  });
155
76
  const storeModalNavigate = (state, action) => {
156
77
  if (action.type !== 'STORE_MODAL_NAVIGATE') return state;
@@ -197,7 +118,7 @@ const reducer = (state, action) => {
197
118
  if (!state) throw 'Reducer called without state';
198
119
  switch (action.type) {
199
120
  case 'POPULATE_PRODUCT_STATE':
200
- return populateProductStateReducer(state, action);
121
+ return repriceSelection(populateProductStateReducer(state, action));
201
122
  case 'PRECART_TOGGLE_LICENSE':
202
123
  return toggleLicense(state, action);
203
124
  case 'PRECART_UNSELECT_LICENSE':
@@ -260,10 +181,11 @@ export function createDefaultStore(config) {
260
181
  return createStore(reducer, {
261
182
  selectedSkuIds: {},
262
183
  // { [skuId]: Boolean }
184
+ requestedSkuIds: [],
263
185
  licenseOptions: [],
264
186
  cartOpen: false,
265
187
  precartOpen: false,
266
- collectionStyleSkus: {},
188
+ skuStyleIds: {},
267
189
  skuPrices: {},
268
190
  collectionSkus: {},
269
191
  fetchedCollectionIds: [],
@@ -0,0 +1,40 @@
1
+ import type { FontdueState } from './reducer.js';
2
+ type Catalog = Pick<FontdueState, 'skuStyleIds' | 'skuPrices'>;
3
+ /**
4
+ * The cheapest set of SKUs in which each of `picks` sits inside one SKU
5
+ * granting all of its styles, sorted. Picks whose styles or price aren't known
6
+ * are left out.
7
+ *
8
+ * @example
9
+ * cheapestSkuIds(['sku:bold', 'sku:regular', 'sku:light'], catalog)
10
+ * // => ['sku:light+regular+bold'] rather than a pair plus a single
11
+ */
12
+ export declare const cheapestSkuIds: (picks: readonly string[], catalog: Catalog) => string[];
13
+ /**
14
+ * Re-derives `selectedSkuIds` from the picks. A pick whose styles or price
15
+ * aren't known yet – selected from the host page before its product data
16
+ * arrived – is bought as it is until they are.
17
+ */
18
+ export declare const repriceSelection: (state: FontdueState) => FontdueState;
19
+ /**
20
+ * Adds a pick, or takes out every pick sharing a style with the clicked SKU.
21
+ *
22
+ * If what's left is still cheapest bought with a SKU granting the clicked one's
23
+ * styles anyway – it's part of a bundle that costs no more without it – the
24
+ * click would change nothing, so the picks inside the selected SKUs granting
25
+ * them go too, as deselecting a member of a selected bundle always has.
26
+ */
27
+ export declare const selectSku: (state: FontdueState, skuId: string, selected: boolean) => FontdueState;
28
+ /**
29
+ * Whether a SKU – or a style with no SKU of its own – is in the selection:
30
+ * selected itself, or every style it grants is granted by what is selected.
31
+ */
32
+ export declare const isSelected: (skuIdOrStyleId: string | null) => (state: FontdueState) => boolean;
33
+ /**
34
+ * What picking a SKU would add to the selection's price: the cheapest
35
+ * combination with it, less the current one. Never more than the SKU's own
36
+ * price, and less when it completes a cheaper bundle – the Store Modal's
37
+ * “+$X”. Null when the SKU's styles or price aren't known.
38
+ */
39
+ export declare const additionalCost: (state: FontdueState, skuId: string) => number | null;
40
+ export {};