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,180 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useRef } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useInfiniteScroll from '../components/useInfiniteScroll.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+ class FakeIntersectionObserver {
8
+ static instances = [];
9
+ observed = [];
10
+ disconnected = false;
11
+ constructor(callback, options) {
12
+ this.callback = callback;
13
+ this.options = options;
14
+ FakeIntersectionObserver.instances.push(this);
15
+ }
16
+ observe(element) {
17
+ this.observed.push(element);
18
+ }
19
+ unobserve() {}
20
+ disconnect() {
21
+ this.disconnected = true;
22
+ }
23
+
24
+ // Report every observed element as having come into view.
25
+ enter() {
26
+ this.callback(this.observed.map(target => ({
27
+ target,
28
+ isIntersecting: true
29
+ })), this);
30
+ }
31
+ }
32
+ const observers = () => FakeIntersectionObserver.instances;
33
+ const live = () => observers().filter(observer => !observer.disconnected);
34
+
35
+ // The shape the store modal index renders: the sentinel stays mounted while a
36
+ // page is in flight (`enabled` false) and only goes away once the list is
37
+ // exhausted, and the scroller is an ancestor whose ref is still null while the
38
+ // sentinel renders.
39
+ function Harness(_ref) {
40
+ let {
41
+ enabled,
42
+ hasMore = true,
43
+ onLoadMore
44
+ } = _ref;
45
+ const scrollContainer = useRef(null);
46
+ return /*#__PURE__*/React.createElement("div", {
47
+ ref: scrollContainer,
48
+ "data-testid": "scroller"
49
+ }, /*#__PURE__*/React.createElement(Sentinel, {
50
+ enabled: enabled,
51
+ hasMore: hasMore,
52
+ onLoadMore: onLoadMore,
53
+ rootRef: scrollContainer
54
+ }));
55
+ }
56
+ function Sentinel(_ref2) {
57
+ let {
58
+ enabled,
59
+ hasMore,
60
+ onLoadMore,
61
+ rootRef
62
+ } = _ref2;
63
+ const sentinelRef = useInfiniteScroll({
64
+ enabled,
65
+ onLoadMore,
66
+ rootRef,
67
+ rootMargin: '0px 0px 600px 0px'
68
+ });
69
+ return hasMore ? /*#__PURE__*/React.createElement("div", {
70
+ ref: sentinelRef,
71
+ "data-testid": "sentinel"
72
+ }) : null;
73
+ }
74
+ let container;
75
+ let root;
76
+ const render = element => {
77
+ act(() => {
78
+ root.render(element);
79
+ });
80
+ };
81
+ beforeEach(() => {
82
+ FakeIntersectionObserver.instances = [];
83
+ vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
84
+ container = document.createElement('div');
85
+ document.body.appendChild(container);
86
+ root = createRoot(container);
87
+ });
88
+ afterEach(() => {
89
+ act(() => root.unmount());
90
+ container.remove();
91
+ vi.unstubAllGlobals();
92
+ });
93
+ describe('useInfiniteScroll', () => {
94
+ it('loads more when the sentinel comes into view', () => {
95
+ const onLoadMore = vi.fn();
96
+ render( /*#__PURE__*/React.createElement(Harness, {
97
+ enabled: true,
98
+ onLoadMore: onLoadMore
99
+ }));
100
+ expect(live()).toHaveLength(1);
101
+ act(() => live()[0].enter());
102
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
103
+ });
104
+ it('watches the scrolling ancestor, whose ref is only attached after render', () => {
105
+ var _options, _options2;
106
+ render( /*#__PURE__*/React.createElement(Harness, {
107
+ enabled: true,
108
+ onLoadMore: vi.fn()
109
+ }));
110
+ const [observer] = live();
111
+ expect((_options = observer.options) === null || _options === void 0 ? void 0 : _options.root).toBe(container.querySelector('[data-testid="scroller"]'));
112
+ expect((_options2 = observer.options) === null || _options2 === void 0 ? void 0 : _options2.rootMargin).toBe('0px 0px 600px 0px');
113
+ expect(observer.observed).toEqual([container.querySelector('[data-testid="sentinel"]')]);
114
+ });
115
+ it('stops watching while a page is in flight, and picks it up again after', () => {
116
+ const onLoadMore = vi.fn();
117
+ render( /*#__PURE__*/React.createElement(Harness, {
118
+ enabled: true,
119
+ onLoadMore: onLoadMore
120
+ }));
121
+ act(() => live()[0].enter());
122
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
123
+
124
+ // The sentinel is still on screen, but `loadNext` is running: nothing
125
+ // should be watching it, so the load isn't asked for again on every frame.
126
+ render( /*#__PURE__*/React.createElement(Harness, {
127
+ enabled: false,
128
+ onLoadMore: onLoadMore
129
+ }));
130
+ expect(live()).toHaveLength(0);
131
+
132
+ // Page arrived, more to come: back to watching, and still in view.
133
+ render( /*#__PURE__*/React.createElement(Harness, {
134
+ enabled: true,
135
+ onLoadMore: onLoadMore
136
+ }));
137
+ expect(live()).toHaveLength(1);
138
+ act(() => live()[0].enter());
139
+ expect(onLoadMore).toHaveBeenCalledTimes(2);
140
+ });
141
+ it('never observes when there is nothing left to load', () => {
142
+ render( /*#__PURE__*/React.createElement(Harness, {
143
+ enabled: false,
144
+ hasMore: false,
145
+ onLoadMore: vi.fn()
146
+ }));
147
+ expect(observers()).toHaveLength(0);
148
+ });
149
+ it('disconnects when the sentinel is removed', () => {
150
+ render( /*#__PURE__*/React.createElement(Harness, {
151
+ enabled: true,
152
+ onLoadMore: vi.fn()
153
+ }));
154
+ const [observer] = observers();
155
+ render( /*#__PURE__*/React.createElement(Harness, {
156
+ enabled: true,
157
+ hasMore: false,
158
+ onLoadMore: vi.fn()
159
+ }));
160
+ expect(observer.disconnected).toBe(true);
161
+ });
162
+ it('keeps watching when only the callback identity changes', () => {
163
+ render( /*#__PURE__*/React.createElement(Harness, {
164
+ enabled: true,
165
+ onLoadMore: () => {}
166
+ }));
167
+ const [observer] = observers();
168
+ const onLoadMore = vi.fn();
169
+ render( /*#__PURE__*/React.createElement(Harness, {
170
+ enabled: true,
171
+ onLoadMore: onLoadMore
172
+ }));
173
+
174
+ // Same observer, and it calls the callback the latest render passed.
175
+ expect(observers()).toHaveLength(1);
176
+ expect(observer.disconnected).toBe(false);
177
+ act(() => observer.enter());
178
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
179
+ });
180
+ });
@@ -10,8 +10,9 @@ function makeState() {
10
10
  cartOpen: false,
11
11
  precartOpen: false,
12
12
  selectedSkuIds: {},
13
+ requestedSkuIds: [],
13
14
  licenseOptions: [],
14
- collectionStyleSkus: {},
15
+ skuStyleIds: {},
15
16
  collectionSkus: {},
16
17
  skuPrices: {},
17
18
  fetchedCollectionIds: [],
@@ -0,0 +1,167 @@
1
+ // @vitest-environment happy-dom
2
+ // @vitest-environment-options { "url": "https://store.example.com/fonts/archivo" }
3
+
4
+ // The browser-only storefront moments reach the server through
5
+ // `trackStorefrontEvent`. A buyer clicks through licences in bursts and React
6
+ // re-runs effects, so what matters here is what *doesn't* get reported: the
7
+ // steps before a configuration settles, a configuration already reported, and
8
+ // a second look at the same collection that is really one mount.
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ const commitMutation = vi.fn();
12
+ vi.mock('react-relay', () => ({
13
+ graphql: () => null,
14
+ commitMutation: function () {
15
+ return commitMutation(...arguments);
16
+ },
17
+ useRelayEnvironment: vi.fn()
18
+ }));
19
+ import { licenseSelectionsFromOptions, trackPaymentInfoEntered, trackProductCustomized, trackProductViewed } from '../components/Cart/storefrontEvents.js';
20
+ const environment = {};
21
+ function reportedInputs() {
22
+ return commitMutation.mock.calls.map(_ref => {
23
+ let [, config] = _ref;
24
+ return config.variables.input;
25
+ });
26
+ }
27
+ beforeEach(() => {
28
+ commitMutation.mockReset();
29
+ vi.useFakeTimers();
30
+ });
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ });
34
+ describe('licenseSelectionsFromOptions', () => {
35
+ it('takes the licence choices in the shape createOrderItems takes them, sorted', () => {
36
+ const options = [{
37
+ licenseId: 'web',
38
+ licenseOptions: {}
39
+ }, {
40
+ licenseId: 'desktop',
41
+ licenseOptions: {
42
+ users: 'five',
43
+ seats: 'one'
44
+ }
45
+ }];
46
+ expect(licenseSelectionsFromOptions(options)).toEqual([{
47
+ licenseId: 'desktop',
48
+ licenseVariableId: 'seats',
49
+ licenseOptionId: 'one'
50
+ }, {
51
+ licenseId: 'desktop',
52
+ licenseVariableId: 'users',
53
+ licenseOptionId: 'five'
54
+ }, {
55
+ licenseId: 'web'
56
+ }]);
57
+ });
58
+ });
59
+ describe('trackProductCustomized', () => {
60
+ it('reports the configuration a burst of clicks settles on, once', () => {
61
+ let options = [{
62
+ licenseId: 'desktop',
63
+ licenseOptions: {
64
+ users: 'one'
65
+ }
66
+ }];
67
+ const current = () => options;
68
+ trackProductCustomized(environment, 'collection-burst', current);
69
+ options = [{
70
+ licenseId: 'desktop',
71
+ licenseOptions: {
72
+ users: 'five'
73
+ }
74
+ }];
75
+ trackProductCustomized(environment, 'collection-burst', current);
76
+ options = [{
77
+ licenseId: 'desktop',
78
+ licenseOptions: {
79
+ users: 'ten'
80
+ }
81
+ }];
82
+ trackProductCustomized(environment, 'collection-burst', current);
83
+ expect(commitMutation).not.toHaveBeenCalled();
84
+ vi.advanceTimersByTime(1500);
85
+ expect(reportedInputs()).toEqual([expect.objectContaining({
86
+ event: 'PRODUCT_CUSTOMIZED',
87
+ collectionId: 'collection-burst',
88
+ licenseSelections: [{
89
+ licenseId: 'desktop',
90
+ licenseVariableId: 'users',
91
+ licenseOptionId: 'ten'
92
+ }],
93
+ tracking: expect.objectContaining({
94
+ url: 'https://store.example.com/fonts/archivo'
95
+ })
96
+ })]);
97
+ });
98
+ it('skips a configuration already reported for that collection', () => {
99
+ let options = [{
100
+ licenseId: 'desktop',
101
+ licenseOptions: {}
102
+ }];
103
+ const current = () => options;
104
+ trackProductCustomized(environment, 'collection-repeat', current);
105
+ vi.advanceTimersByTime(1500);
106
+
107
+ // Over to web and back again: the buyer ends where they were.
108
+ options = [{
109
+ licenseId: 'web',
110
+ licenseOptions: {}
111
+ }];
112
+ trackProductCustomized(environment, 'collection-repeat', current);
113
+ options = [{
114
+ licenseId: 'desktop',
115
+ licenseOptions: {}
116
+ }];
117
+ trackProductCustomized(environment, 'collection-repeat', current);
118
+ vi.advanceTimersByTime(1500);
119
+
120
+ // Then a real change.
121
+ options = [{
122
+ licenseId: 'web',
123
+ licenseOptions: {}
124
+ }];
125
+ trackProductCustomized(environment, 'collection-repeat', current);
126
+ vi.advanceTimersByTime(1500);
127
+ expect(reportedInputs().map(input => input.licenseSelections)).toEqual([[{
128
+ licenseId: 'desktop'
129
+ }], [{
130
+ licenseId: 'web'
131
+ }]]);
132
+ });
133
+ it('reports nothing when every licence is unticked', () => {
134
+ trackProductCustomized(environment, 'collection-empty', () => []);
135
+ vi.advanceTimersByTime(1500);
136
+ expect(commitMutation).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+ describe('trackProductViewed', () => {
140
+ it('counts an effect run twice for one mount as one view', () => {
141
+ trackProductViewed(environment, 'collection-view');
142
+ trackProductViewed(environment, 'collection-view');
143
+ trackProductViewed(environment, 'collection-other');
144
+
145
+ // Coming back to the first collection later is a new look.
146
+ vi.advanceTimersByTime(2000);
147
+ trackProductViewed(environment, 'collection-view');
148
+ expect(reportedInputs().map(_ref2 => {
149
+ let {
150
+ event,
151
+ collectionId
152
+ } = _ref2;
153
+ return [event, collectionId];
154
+ })).toEqual([['PRODUCT_VIEWED', 'collection-view'], ['PRODUCT_VIEWED', 'collection-other'], ['PRODUCT_VIEWED', 'collection-view']]);
155
+ });
156
+ });
157
+ describe('trackPaymentInfoEntered', () => {
158
+ it('reports with the buyer’s tracking context', () => {
159
+ trackPaymentInfoEntered(environment);
160
+ expect(reportedInputs()).toEqual([expect.objectContaining({
161
+ event: 'PAYMENT_INFO_ENTERED',
162
+ tracking: expect.objectContaining({
163
+ analyticsConsent: false
164
+ })
165
+ })]);
166
+ });
167
+ });
@@ -0,0 +1,138 @@
1
+ import "../global-shim.js";
2
+ // @vitest-environment happy-dom
3
+ import React, { act, useState } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { ContentState, EditorState } from 'draft-js';
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7
+ import TypeTesterFitter from '../components/TypeTester/TypeTesterFitter.js';
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ // happy-dom has no text layout, so the two measuring primitives are stubbed
11
+ // with a toy font: every glyph is 0.5em wide, and the `ss01` alternates are
12
+ // 0.6em. Canvas only ever sees the default glyphs – that is true of real
13
+ // browsers too (`ctx.font` has no feature settings), and is why autofit sized
14
+ // a tester for the default glyphs and then rendered it with the alternates.
15
+ const DEFAULT_ADVANCE = 0.5;
16
+ const SS01_ADVANCE = 0.6;
17
+ const TEXT = 'UNICASED CIRCUMSTANCES';
18
+ const CONTAINER_WIDTH = 1010;
19
+ // useTypeTesterStyler keeps 10px of slack when autofit is on.
20
+ const AVAILABLE_WIDTH = CONTAINER_WIDTH - 10;
21
+ const renderedWidth = (size, features) => TEXT.length * size * (features.includes('ss01') ? SS01_ADVANCE : DEFAULT_ADVANCE);
22
+ let domMeasure;
23
+ beforeEach(() => {
24
+ vi.stubGlobal('ResizeObserver', class {
25
+ constructor(callback) {
26
+ this.callback = callback;
27
+ }
28
+ observe() {
29
+ this.callback([{
30
+ contentRect: {
31
+ width: CONTAINER_WIDTH
32
+ }
33
+ }], this);
34
+ }
35
+ disconnect() {}
36
+ });
37
+ Object.defineProperty(document, 'fonts', {
38
+ configurable: true,
39
+ value: {
40
+ load: () => Promise.resolve([])
41
+ }
42
+ });
43
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
44
+ font: '',
45
+ letterSpacing: '',
46
+ measureText: text => ({
47
+ width: text.length * 100 * DEFAULT_ADVANCE
48
+ })
49
+ });
50
+ domMeasure = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function () {
51
+ const {
52
+ fontSize,
53
+ fontFeatureSettings
54
+ } = this.style;
55
+ const advance = fontFeatureSettings.includes('ss01') ? SS01_ADVANCE : DEFAULT_ADVANCE;
56
+ return {
57
+ width: (this.textContent ?? '').length * parseFloat(fontSize) * advance
58
+ };
59
+ });
60
+ });
61
+ afterEach(() => {
62
+ vi.restoreAllMocks();
63
+ vi.unstubAllGlobals();
64
+ });
65
+ let currentSize = 0;
66
+ function Tester(_ref) {
67
+ let {
68
+ features
69
+ } = _ref;
70
+ const [size, setSize] = useState(72);
71
+ const [content] = useState(() => EditorState.createWithContent(ContentState.createFromText(TEXT)));
72
+ currentSize = size;
73
+ return /*#__PURE__*/React.createElement(TypeTesterFitter, {
74
+ autofit: true,
75
+ autofitOnChange: false,
76
+ min: 10,
77
+ max: 200,
78
+ size: size,
79
+ setSize: setSize,
80
+ lineHeight: 1.2,
81
+ letterSpacing: 0,
82
+ features: features,
83
+ focused: false,
84
+ truncate: false,
85
+ fontStyles: {
86
+ fontFamily: 'Toy Sans',
87
+ fontWeight: 200,
88
+ fontStyle: 'italic'
89
+ },
90
+ content: content,
91
+ contentEdited: false,
92
+ alignment: "left",
93
+ variableSettings: null,
94
+ columns: false
95
+ }, TEXT);
96
+ }
97
+ describe('type tester autofit with OpenType features', () => {
98
+ let root;
99
+ let container;
100
+ const render = features => act(async () => {
101
+ root.render( /*#__PURE__*/React.createElement(Tester, {
102
+ features: features
103
+ }));
104
+ });
105
+ beforeEach(() => {
106
+ container = document.createElement('div');
107
+ document.body.appendChild(container);
108
+ root = createRoot(container);
109
+ });
110
+ afterEach(async () => {
111
+ await act(async () => root.unmount());
112
+ container.remove();
113
+ });
114
+ it('fits text on one line when an enabled feature widens it', async () => {
115
+ await render([]);
116
+ const plainSize = currentSize;
117
+ expect(renderedWidth(plainSize, [])).toBeLessThanOrEqual(AVAILABLE_WIDTH);
118
+ await act(async () => root.unmount());
119
+ root = createRoot(container);
120
+ await render(['ss01']);
121
+ expect(currentSize).toBeLessThan(plainSize);
122
+ expect(renderedWidth(currentSize, ['ss01'])).toBeLessThanOrEqual(AVAILABLE_WIDTH);
123
+ });
124
+ it('refits when a feature is toggled', async () => {
125
+ await render([]);
126
+ const plainSize = currentSize;
127
+ await render(['ss01']);
128
+ const featureSize = currentSize;
129
+ expect(featureSize).toBeLessThan(plainSize);
130
+ expect(renderedWidth(featureSize, ['ss01'])).toBeLessThanOrEqual(AVAILABLE_WIDTH);
131
+ await render([]);
132
+ expect(currentSize).toBe(plainSize);
133
+ });
134
+ it('keeps the canvas fast path when no feature is on', async () => {
135
+ await render([]);
136
+ expect(domMeasure).not.toHaveBeenCalled();
137
+ });
138
+ });
@@ -4,9 +4,10 @@ import _CheckoutUpdateOrderMutation from "../../__generated__/CheckoutUpdateOrde
4
4
  import _Checkout_UpdateOrderErrors from "../../__generated__/Checkout_UpdateOrderErrors.graphql.js";
5
5
  import _CheckoutUpdateCustomerMutation from "../../__generated__/CheckoutUpdateCustomerMutation.graphql.js";
6
6
  import _Checkout_identity from "../../__generated__/Checkout_identity.graphql.js";
7
- import React, { Fragment, useContext, useEffect, useState } from 'react';
7
+ import React, { Fragment, useContext, useEffect, useRef, useState } from 'react';
8
8
  import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
9
9
  import { CardElement } from '@stripe/react-stripe-js';
10
+ import { trackPaymentInfoEntered } from './storefrontEvents.js';
10
11
  import { CreditCard, User, Check, License, Receipt, X } from '../Icons/index.js';
11
12
  import AddressFields from './AddressFields.js';
12
13
  import CustomerFields from './CustomerFields.js';
@@ -99,6 +100,7 @@ const Checkout = _ref => {
99
100
  onScrollToTop();
100
101
  }, [checkoutStep]);
101
102
  const environment = useRelayEnvironment();
103
+ const paymentInfoTracked = useRef(false);
102
104
  const [errorsObject, setErrorsObject] = useState(null);
103
105
  const [customerErrorsObject, setCustomerErrorsObject] = useState(null);
104
106
  const onCompleted = (res, errors, onSuccess) => {
@@ -454,6 +456,14 @@ const Checkout = _ref => {
454
456
  style: inputStyles,
455
457
  hidePostalCode: true
456
458
  }
459
+ // The first time the card details are complete. The server
460
+ // keeps it to one per order; the ref only saves requests.
461
+ ,
462
+ onChange: event => {
463
+ if (!event.complete || paymentInfoTracked.current) return;
464
+ paymentInfoTracked.current = true;
465
+ trackPaymentInfoEntered(environment);
466
+ }
457
467
  })), errorsObject && errorsObject.stripePaymentMethodId && errorsObject.stripePaymentMethodId.length > 0 && /*#__PURE__*/React.createElement("p", {
458
468
  className: "checkout-modal__section__errors"
459
469
  }, errorsObject.stripePaymentMethodId[0]));
@@ -0,0 +1,34 @@
1
+ import { Environment } from 'relay-runtime';
2
+ import type { FontdueState } from '../../reducer.js';
3
+ import type { LicenseSelectionInput } from '__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js';
4
+ /** Reports the store modal opening on a collection. */
5
+ export declare function trackProductViewed(environment: Environment, collectionId: string): void;
6
+ type LicenseOptions = FontdueState['licenseOptions'];
7
+ /**
8
+ * The licence choices in the store's state, in the shape `createOrderItems`
9
+ * takes them – one selection per licence variable, or one bare selection for a
10
+ * licence without variables. Sorted, so the same configuration reached in a
11
+ * different order reads the same.
12
+ */
13
+ export declare function licenseSelectionsFromOptions(licenseOptions: LicenseOptions): LicenseSelectionInput[];
14
+ /**
15
+ * Reports the buyer changing their licence choices for a collection, once the
16
+ * choices settle. Call it from the handler of the buyer's own click, never from
17
+ * an effect: licences a foundry marks as selected by default are applied on
18
+ * mount, and that isn't the buyer customizing anything.
19
+ *
20
+ * `currentLicenseOptions` is read when the report fires, so it sees the
21
+ * configuration the buyer settled on.
22
+ */
23
+ export declare function trackProductCustomized(environment: Environment, collectionId: string, currentLicenseOptions: () => LicenseOptions): void;
24
+ /**
25
+ * `trackProductCustomized` bound to the store and Relay environment in
26
+ * context, for a licence control's click handlers.
27
+ */
28
+ export declare function useTrackProductCustomized(collectionId: string | null | undefined): () => void;
29
+ /**
30
+ * Reports the checkout's payment details as complete. The server records it
31
+ * once per order, however often this is called.
32
+ */
33
+ export declare function trackPaymentInfoEntered(environment: Environment): void;
34
+ export {};