fontdue-js 3.6.0 → 3.8.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 (167) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -4
  3. package/dist/__generated__/AddToCartBannerQuery.graphql.d.ts +1 -1
  4. package/dist/__generated__/AddToCartBannerQuery.graphql.js +16 -10
  5. package/dist/__generated__/AddToCartBanner_item.graphql.d.ts +4 -1
  6. package/dist/__generated__/AddToCartBanner_item.graphql.js +7 -2
  7. package/dist/__generated__/AddToCartBanner_order.graphql.d.ts +4 -1
  8. package/dist/__generated__/AddToCartBanner_order.graphql.js +6 -1
  9. package/dist/__generated__/BuyButtonIDQuery.graphql.d.ts +3 -3
  10. package/dist/__generated__/BuyButtonIDQuery.graphql.js +31 -16
  11. package/dist/__generated__/BuyButtonPackIDQuery.graphql.d.ts +21 -0
  12. package/dist/__generated__/BuyButtonPackIDQuery.graphql.js +129 -0
  13. package/dist/__generated__/BuyButtonPackSlugQuery.graphql.d.ts +25 -0
  14. package/dist/__generated__/BuyButtonPackSlugQuery.graphql.js +167 -0
  15. package/dist/__generated__/BuyButtonSlugQuery.graphql.d.ts +3 -3
  16. package/dist/__generated__/BuyButtonSlugQuery.graphql.js +45 -17
  17. package/dist/__generated__/BuyButton_product.graphql.d.ts +27 -0
  18. package/dist/__generated__/BuyButton_product.graphql.js +72 -0
  19. package/dist/__generated__/CartItemProduct_product.graphql.d.ts +1 -1
  20. package/dist/__generated__/CartItemProduct_product.graphql.js +7 -2
  21. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.d.ts +1 -1
  22. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.js +26 -20
  23. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.d.ts +1 -1
  24. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.js +26 -20
  25. package/dist/__generated__/CartOrderUpdateMutation.graphql.d.ts +1 -1
  26. package/dist/__generated__/CartOrderUpdateMutation.graphql.js +26 -20
  27. package/dist/__generated__/CartQuery.graphql.d.ts +1 -1
  28. package/dist/__generated__/CartQuery.graphql.js +26 -20
  29. package/dist/__generated__/CartStateUpdateMutation.graphql.d.ts +1 -1
  30. package/dist/__generated__/CartStateUpdateMutation.graphql.js +26 -20
  31. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +1 -1
  32. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +26 -20
  33. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.d.ts +1 -1
  34. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.js +26 -20
  35. package/dist/__generated__/CollectionAa_Query.graphql.d.ts +1 -1
  36. package/dist/__generated__/CollectionAa_Query.graphql.js +20 -14
  37. package/dist/__generated__/CollectionAa_product.graphql.d.ts +7 -1
  38. package/dist/__generated__/CollectionAa_product.graphql.js +22 -16
  39. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  40. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  41. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +3 -3
  42. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +9 -9
  43. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.d.ts +25 -0
  44. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.js +108 -0
  45. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
  46. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +32 -26
  47. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  48. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  49. package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
  50. package/dist/__generated__/StoreModalCartQuery.graphql.js +37 -31
  51. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  52. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  53. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.d.ts +23 -0
  54. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.js +58 -0
  55. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  56. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  57. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +6 -3
  58. package/dist/__generated__/StoreModalIndexQuery.graphql.js +218 -117
  59. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  60. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  61. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.d.ts +33 -0
  62. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.js +119 -0
  63. package/dist/__generated__/StoreModalPack_fontPack.graphql.d.ts +45 -0
  64. package/dist/__generated__/StoreModalPack_fontPack.graphql.js +150 -0
  65. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +6 -2
  66. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +43 -19
  67. package/dist/__generated__/{StoreModalProductLicenseSelection_collection.graphql.d.ts → StoreModalProductLicenseSelection_licenseable.graphql.d.ts} +7 -6
  68. package/dist/__generated__/StoreModalProductLicenseSelection_licenseable.graphql.js +43 -0
  69. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  70. package/dist/__generated__/StoreModalProductQuery.graphql.js +199 -142
  71. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  72. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +190 -133
  73. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +5 -1
  74. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +254 -176
  75. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.d.ts +1 -1
  76. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.js +9 -4
  77. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.d.ts +7 -1
  78. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.js +7 -2
  79. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
  80. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +26 -20
  81. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  82. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  83. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  84. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  85. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  86. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  87. package/dist/__generated__/productState_Query.graphql.d.ts +99 -1
  88. package/dist/__generated__/productState_Query.graphql.js +43 -4
  89. package/dist/__generated__/productState_fontPack.graphql.d.ts +111 -0
  90. package/dist/__generated__/productState_fontPack.graphql.js +117 -0
  91. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  92. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  93. package/dist/__tests__/bundleSelectionOrder.test.js +94 -0
  94. package/dist/__tests__/collectionBundleSelection.test.js +443 -1446
  95. package/dist/__tests__/fontPackSelection.test.js +435 -0
  96. package/dist/__tests__/infiniteScroll.test.js +180 -0
  97. package/dist/__tests__/licenseExclusions.test.js +4 -1
  98. package/dist/__tests__/storeModalPackButton.test.js +102 -0
  99. package/dist/__tests__/storeModalPackUnlisted.test.js +112 -0
  100. package/dist/__tests__/storeModalRoutes.test.js +9 -0
  101. package/dist/__tests__/storefrontEvents.test.js +167 -0
  102. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  103. package/dist/components/AddToCartBanner/index.js +1 -1
  104. package/dist/components/BuyButton/index.d.ts +29 -9
  105. package/dist/components/BuyButton/index.js +123 -26
  106. package/dist/components/BuyButton/index.server.d.ts +4 -2
  107. package/dist/components/BuyButton/index.server.js +24 -3
  108. package/dist/components/Cart/CartItem/CartItemProduct.js +3 -3
  109. package/dist/components/Cart/Checkout.js +11 -1
  110. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  111. package/dist/components/Cart/storefrontEvents.js +138 -0
  112. package/dist/components/CollectionAa/index.js +2 -2
  113. package/dist/components/FontFamilies/index.js +4 -3
  114. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +1 -1
  115. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +45 -21
  116. package/dist/components/NodePasswordForm/index.d.ts +7 -4
  117. package/dist/components/NodePasswordForm/index.js +6 -6
  118. package/dist/components/Precart/index.js +6 -0
  119. package/dist/components/PriceBar/index.js +5 -3
  120. package/dist/components/Root/productState.d.ts +2 -8
  121. package/dist/components/Root/productState.js +100 -61
  122. package/dist/components/SKUPrice/index.js +7 -8
  123. package/dist/components/StoreModal/StoreModalFamily.d.ts +7 -0
  124. package/dist/components/StoreModal/StoreModalFamily.js +11 -7
  125. package/dist/components/StoreModal/StoreModalIndex.js +58 -7
  126. package/dist/components/StoreModal/StoreModalIndexEntry.d.ts +13 -0
  127. package/dist/components/StoreModal/StoreModalIndexEntry.js +43 -0
  128. package/dist/components/StoreModal/StoreModalIndexItem.js +7 -30
  129. package/dist/components/StoreModal/StoreModalIndexPackItem.d.ts +7 -0
  130. package/dist/components/StoreModal/StoreModalIndexPackItem.js +21 -0
  131. package/dist/components/StoreModal/StoreModalPack.d.ts +9 -0
  132. package/dist/components/StoreModal/StoreModalPack.js +79 -0
  133. package/dist/components/StoreModal/StoreModalPackButton.d.ts +8 -0
  134. package/dist/components/StoreModal/StoreModalPackButton.js +53 -0
  135. package/dist/components/StoreModal/StoreModalProduct.js +49 -19
  136. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  137. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  138. package/dist/components/StoreModalProductLicenseSelection/index.d.ts +3 -3
  139. package/dist/components/StoreModalProductLicenseSelection/index.js +7 -6
  140. package/dist/components/StoreModalProductSummary/index.js +1 -1
  141. package/dist/components/TypeTester/useTypeTesterStyler.js +1 -0
  142. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  143. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  144. package/dist/components/elements/StoreModalFamily/index.d.ts +1 -1
  145. package/dist/components/elements/StoreModalFamily/index.js +1 -1
  146. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  147. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  148. package/dist/components/elements/StoreModalUnifiedCheckout.js +12 -0
  149. package/dist/components/useInfiniteScroll.d.ts +28 -0
  150. package/dist/components/useInfiniteScroll.js +50 -0
  151. package/dist/fontdue.css +39 -0
  152. package/dist/hooks/useAutofit.d.ts +2 -1
  153. package/dist/hooks/useAutofit.js +10 -4
  154. package/dist/reducer.d.ts +12 -5
  155. package/dist/reducer.js +18 -87
  156. package/dist/selection.d.ts +44 -0
  157. package/dist/selection.js +539 -0
  158. package/dist/storeModalRoutes.js +5 -4
  159. package/dist/utils.d.ts +1 -12
  160. package/dist/utils.js +1 -36
  161. package/dist/version.js +1 -1
  162. package/package.json +1 -1
  163. package/dist/__generated__/BuyButton_collection.graphql.d.ts +0 -25
  164. package/dist/__generated__/BuyButton_collection.graphql.js +0 -49
  165. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +0 -40
  166. package/fontdue.css +0 -2733
  167. package/fontdue.css.map +0 -1
@@ -0,0 +1,435 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // Mock react-relay's graphql tag since productState.ts uses it at import time
4
+ vi.mock('react-relay', () => ({
5
+ graphql: () => null,
6
+ fetchQuery: vi.fn()
7
+ }));
8
+ import reducer, { isSelected } from '../reducer.js';
9
+ import { additionalCost } from '../selection.js';
10
+ function makeState() {
11
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
12
+ return {
13
+ cartOpen: false,
14
+ precartOpen: false,
15
+ selectedSkuIds: {},
16
+ requestedSkuIds: [],
17
+ licenseOptions: [],
18
+ skuStyleIds: {},
19
+ collectionSkus: {},
20
+ productSkuIds: {},
21
+ viewedProductId: null,
22
+ skuPrices: {},
23
+ fetchedCollectionIds: [],
24
+ storeModalRoute: {
25
+ name: 'index'
26
+ },
27
+ storeModalHistory: [],
28
+ orderVariableSelections: [],
29
+ licenseeIsBillingIdentity: null,
30
+ ...overrides
31
+ };
32
+ }
33
+ const sku = (id, amount) => ({
34
+ id,
35
+ price: {
36
+ amount
37
+ }
38
+ });
39
+ const style = (id, skuId, amount) => ({
40
+ id,
41
+ sku: sku(skuId, amount)
42
+ });
43
+ const selected = state => Object.keys(state.selectedSkuIds).filter(skuId => state.selectedSkuIds[skuId]).sort();
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Fixture: "Starter Pack" = two styles picked out of Thunder Text (a family
47
+ // that is NOT a member as a whole) + all of Thunder Display (a whole-family
48
+ // member). Pack $400; styles $100 each; Display family $250.
49
+ // ---------------------------------------------------------------------------
50
+ const packNode = {
51
+ __typename: 'FontPack',
52
+ id: 'pack-id',
53
+ name: 'Starter Pack',
54
+ sku: sku('pack-sku', 400),
55
+ fontStyles: [style('f1', 's1', 100), style('f2', 's2', 100), style('df1', 'ds1', 100), style('df2', 'ds2', 100)],
56
+ members: [{
57
+ __typename: 'FontStyle'
58
+ }, {
59
+ __typename: 'FontStyle'
60
+ }, {
61
+ __typename: 'FontCollection',
62
+ id: 'display-id',
63
+ name: 'Thunder Display',
64
+ sku: sku('display-sku', 250),
65
+ fontStyles: [style('df1', 'ds1', 100), style('df2', 'ds2', 100)],
66
+ bundles: null,
67
+ children: []
68
+ }]
69
+ };
70
+ const populate = function (node) {
71
+ let state = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : makeState();
72
+ return reducer(state, {
73
+ type: 'POPULATE_PRODUCT_STATE',
74
+ data: {
75
+ node
76
+ }
77
+ });
78
+ };
79
+ const select = function (state, skuId) {
80
+ let isSelectedNow = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true;
81
+ return reducer(state, {
82
+ type: 'SELECT_SKU_ID',
83
+ skuId,
84
+ selected: isSelectedNow
85
+ });
86
+ };
87
+ describe('populateProductStateReducer with a font pack', () => {
88
+ const state = populate(packNode);
89
+ it('registers the pack SKU as granting every style the pack lists', () => {
90
+ expect(state.skuStyleIds['pack-sku']).toEqual(['f1', 'f2', 'df1', 'df2']);
91
+ });
92
+ it('registers a whole-family member like its own product page would', () => {
93
+ expect(state.skuStyleIds['display-sku']).toEqual(['df1', 'df2']);
94
+ expect(state.skuStyleIds['ds1']).toEqual(['df1']);
95
+ expect(state.skuStyleIds['ds2']).toEqual(['df2']);
96
+ });
97
+ it('registers each style member by its own SKU', () => {
98
+ expect(state.skuStyleIds['s1']).toEqual(['f1']);
99
+ expect(state.skuStyleIds['s2']).toEqual(['f2']);
100
+ });
101
+ it('records a price for every SKU seen', () => {
102
+ expect(state.skuPrices).toEqual({
103
+ 'pack-sku': 400,
104
+ 'display-sku': 250,
105
+ s1: 100,
106
+ s2: 100,
107
+ ds1: 100,
108
+ ds2: 100
109
+ });
110
+ });
111
+ it('maps the pack id to its SKU and marks it fetched', () => {
112
+ expect(state.collectionSkus['pack-id']).toBe('pack-sku');
113
+ expect(state.fetchedCollectionIds).toContain('pack-id');
114
+ });
115
+ it('ignores nodes that are neither a collection nor a pack', () => {
116
+ const before = makeState();
117
+ expect(populate({
118
+ __typename: '%other'
119
+ }, before)).toBe(before);
120
+ expect(populate(null, before)).toBe(before);
121
+ });
122
+ });
123
+ describe('selecting the pack SKU', () => {
124
+ const state = select(populate(packNode), 'pack-sku');
125
+ it('marks every member style selected', () => {
126
+ expect(selected(state)).toEqual(['pack-sku']);
127
+ for (const skuId of ['s1', 's2', 'ds1', 'ds2']) {
128
+ expect(isSelected(skuId)(state)).toBe(true);
129
+ }
130
+ // by style id too, for buttons whose style has no SKU
131
+ expect(isSelected('f1')(state)).toBe(true);
132
+ });
133
+ it('marks the whole-family member selected, as covered by the pack', () => {
134
+ expect(isSelected('display-sku')(state)).toBe(true);
135
+ expect(state.selectedSkuIds['display-sku']).toBeUndefined();
136
+ });
137
+ it('deselecting a member style deselects the pack', () => {
138
+ const after = select(state, 'ds1', false);
139
+ expect(selected(after)).toEqual([]);
140
+ expect(isSelected('s1')(after)).toBe(false);
141
+ expect(isSelected('display-sku')(after)).toBe(false);
142
+ });
143
+ });
144
+ describe('selecting member styles one by one', () => {
145
+ it('buys the pack once it is the cheaper buy', () => {
146
+ let state = populate(packNode);
147
+ state = select(state, 's1');
148
+ state = select(state, 's2');
149
+ state = select(state, 'ds1');
150
+ // $300 of styles; the pack at $400 is still dearer
151
+ expect(selected(state)).toEqual(['ds1', 's1', 's2']);
152
+ expect(additionalCost(state, 'ds2')).toBe(100);
153
+ state = select(state, 'ds2');
154
+ // every style: $400 = the pack, which wins the tie as one SKU
155
+ expect(selected(state)).toEqual(['pack-sku']);
156
+ for (const skuId of ['s1', 's2', 'ds1', 'ds2']) {
157
+ expect(isSelected(skuId)(state)).toBe(true);
158
+ }
159
+ });
160
+ it('buys the pack when the whole-family member completes it', () => {
161
+ let state = populate(packNode);
162
+ state = select(state, 's1');
163
+ state = select(state, 's2');
164
+
165
+ // Display family is $250; the pack costs $200 more than what's selected
166
+ expect(additionalCost(state, 'display-sku')).toBe(200);
167
+ state = select(state, 'display-sku');
168
+ expect(selected(state)).toEqual(['pack-sku']);
169
+ expect(isSelected('display-sku')(state)).toBe(true);
170
+ });
171
+ it('does not buy the pack while it is dearer than the styles', () => {
172
+ let state = populate({
173
+ ...packNode,
174
+ sku: sku('pack-sku', 1000)
175
+ });
176
+ for (const skuId of ['s1', 's2', 'ds1', 'ds2']) state = select(state, skuId);
177
+ // nor Display: $250 is more than its two $100 styles
178
+ expect(selected(state)).toEqual(['ds1', 'ds2', 's1', 's2']);
179
+ });
180
+ });
181
+ describe('a superfamily member', () => {
182
+ const superPackNode = {
183
+ ...packNode,
184
+ fontStyles: [style('f1', 's1', 100), style('f2', 's2', 100), style('cf1', 'cs1', 100)],
185
+ members: [{
186
+ __typename: 'FontCollection',
187
+ id: 'super-id',
188
+ name: 'Thunder Collection',
189
+ sku: sku('super-sku', 350),
190
+ fontStyles: [],
191
+ bundles: null,
192
+ children: [{
193
+ id: 'text-id',
194
+ name: 'Thunder Text',
195
+ sku: sku('text-sku', 180),
196
+ fontStyles: [style('f1', 's1', 100), style('f2', 's2', 100)],
197
+ bundles: [{
198
+ id: 'bundle-id',
199
+ name: 'Thunder Text Bundle',
200
+ sku: sku('bundle-sku', 150),
201
+ fontStyles: [style('f1', 's1', 100), style('f2', 's2', 100)]
202
+ }]
203
+ }, {
204
+ id: 'caption-id',
205
+ name: 'Thunder Caption',
206
+ sku: sku('caption-sku', 90),
207
+ fontStyles: [style('cf1', 'cs1', 100)],
208
+ bundles: null
209
+ }]
210
+ }]
211
+ };
212
+ const state = populate(superPackNode);
213
+ it('registers the superfamily, its families and their bundles under the pack', () => {
214
+ expect(state.skuStyleIds['pack-sku']).toEqual(['f1', 'f2', 'cf1']);
215
+ expect(state.skuStyleIds['super-sku']).toEqual(['f1', 'f2', 'cf1']);
216
+ expect(state.skuStyleIds['text-sku']).toEqual(['f1', 'f2']);
217
+ expect(state.skuStyleIds['bundle-sku']).toEqual(['f1', 'f2']);
218
+ expect(state.skuStyleIds['caption-sku']).toEqual(['cf1']);
219
+ });
220
+ it('selecting the pack marks the families and bundles selected', () => {
221
+ const after = select(state, 'pack-sku');
222
+ for (const skuId of ['super-sku', 'text-sku', 'caption-sku', 'bundle-sku', 's1', 'cs1']) {
223
+ expect(isSelected(skuId)(after)).toBe(true);
224
+ }
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Every product a buyer has seen – or has a BuyButton for on the host page –
230
+ // stays registered, so on a product page a pick the page shows only sits
231
+ // inside SKUs the page shows. The page reports the product it shows.
232
+ // ---------------------------------------------------------------------------
233
+ const viewing = (state, productId) => reducer(state, {
234
+ type: 'STORE_MODAL_VIEW_PRODUCT',
235
+ productId
236
+ });
237
+ const family = (id, name, skuId, amount, styles) => ({
238
+ __typename: 'FontCollection',
239
+ id,
240
+ name,
241
+ sku: sku(skuId, amount),
242
+ fontStyles: styles,
243
+ bundles: null,
244
+ children: []
245
+ });
246
+
247
+ // Display at $180 is bought once both its $100 styles are picked; a $150 pack
248
+ // over it would be cheaper still.
249
+ const displayAt180 = {
250
+ ...packNode.members[2],
251
+ sku: sku('display-sku', 180)
252
+ };
253
+ const cheapPack = {
254
+ ...packNode,
255
+ sku: sku('pack-sku', 150),
256
+ members: [displayAt180]
257
+ };
258
+ describe('on a product page', () => {
259
+ it("buys a family's styles through the family, not a pack seen earlier", () => {
260
+ // the pack page registered Display as a member; its own page then
261
+ // registers it as the product being viewed
262
+ const display = family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]);
263
+ let state = viewing(populate(display, populate(cheapPack)), 'display-id');
264
+ state = select(state, 'ds1');
265
+ expect(selected(state)).toEqual(['ds1']);
266
+ state = select(state, 'ds2');
267
+ expect(selected(state)).toEqual(['display-sku']);
268
+ expect(isSelected('ds1')(state)).toBe(true);
269
+ });
270
+ it("does not buy the pack page's partial block through a family seen earlier", () => {
271
+ const text = family('text-id', 'Thunder Text', 'text-sku', 180, [style('f1', 's1', 100), style('f2', 's2', 100)]);
272
+ let state = viewing(populate(packNode, populate(text)), 'pack-id');
273
+ state = select(state, 's1');
274
+ state = select(state, 's2');
275
+ expect(selected(state)).toEqual(['s1', 's2']);
276
+ });
277
+ it('still buys a whole-family member through its own SKU on the pack page', () => {
278
+ let state = viewing(populate({
279
+ ...packNode,
280
+ members: [displayAt180]
281
+ }), 'pack-id');
282
+ state = select(state, 'ds1');
283
+ state = select(state, 'ds2');
284
+ expect(selected(state)).toEqual(['display-sku']);
285
+ });
286
+ it("keeps a family that has no SKU of its own to its page, so a pack seen earlier can't claim it", () => {
287
+ // Display sells à la carte only: there is no family SKU on its page, and
288
+ // the pack another page registered must not stand in for one
289
+ const display = {
290
+ ...family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]),
291
+ sku: null
292
+ };
293
+ let state = viewing(populate(display, populate(cheapPack)), 'display-id');
294
+ state = select(state, 'ds1');
295
+ expect(additionalCost(state, 'ds2')).toBe(100);
296
+ state = select(state, 'ds2');
297
+ expect(selected(state)).toEqual(['ds1', 'ds2']);
298
+ });
299
+ it('scopes a page opened by slug, once it reports the product it shows', () => {
300
+ const display = {
301
+ ...family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]),
302
+ sku: null
303
+ };
304
+ let state = reducer(populate(display, populate(cheapPack)), {
305
+ type: 'STORE_MODAL_NAVIGATE',
306
+ route: 'product',
307
+ variables: {
308
+ productSlug: 'thunder-display'
309
+ }
310
+ });
311
+ state = viewing(state, 'display-id');
312
+ state = select(select(state, 'ds1'), 'ds2');
313
+ expect(selected(state)).toEqual(['ds1', 'ds2']);
314
+ });
315
+ it('keeps every registered SKU once the page is left', () => {
316
+ const display = {
317
+ ...family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]),
318
+ sku: null
319
+ };
320
+ let state = viewing(populate(display, populate(cheapPack)), 'display-id');
321
+ state = select(select(state, 'ds1'), 'ds2');
322
+ expect(selected(viewing(state, null))).toEqual(['pack-sku']);
323
+ });
324
+ it('re-prices when the page changes', () => {
325
+ const display = {
326
+ ...family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]),
327
+ sku: null
328
+ };
329
+ let state = viewing(populate(display, populate(cheapPack)), 'display-id');
330
+ state = select(select(state, 'ds1'), 'ds2');
331
+ expect(selected(state)).toEqual(['ds1', 'ds2']);
332
+ // on the pack page the pack is shown, and cheaper
333
+ expect(selected(viewing(state, 'pack-id'))).toEqual(['pack-sku']);
334
+ });
335
+ it('keeps a pick in a SKU identical to it that the page does not show', () => {
336
+ // a one-style pack at a style's price is the same purchase as the style;
337
+ // only one of the two is offered, and it may be the one off this page
338
+ const oneStylePack = {
339
+ ...packNode,
340
+ id: 'a-pack-id',
341
+ sku: sku('a-pack-sku', 100),
342
+ fontStyles: [style('df1', 'ds1', 100)],
343
+ members: [{
344
+ __typename: 'FontStyle'
345
+ }]
346
+ };
347
+ const display = family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100), style('df2', 'ds2', 100)]);
348
+ let state = viewing(populate(display, populate(oneStylePack)), 'display-id');
349
+ state = select(state, 'ds1');
350
+ // not pushed into the $180 family for want of a place to sit
351
+ expect(selected(state)).toHaveLength(1);
352
+ expect(isSelected('ds1')(state)).toBe(true);
353
+ expect(additionalCost(state, 'ds2')).toBe(80);
354
+ });
355
+ it('remembers every SKU each product page registered', () => {
356
+ const display = family('display-id', 'Thunder Display', 'display-sku', 180, [style('df1', 'ds1', 100)]);
357
+ const state = populate(display, populate(cheapPack));
358
+ // the pack, its two Thunder Text styles, and the whole-family member with its styles
359
+ expect([...state.productSkuIds['pack-id']].sort()).toEqual(['display-sku', 'ds1', 'ds2', 'pack-sku', 's1', 's2']);
360
+ expect([...state.productSkuIds['display-id']].sort()).toEqual(['display-sku', 'ds1']);
361
+ expect(populate({
362
+ ...display,
363
+ sku: null
364
+ }).productSkuIds['display-id']).toEqual(['ds1']);
365
+ });
366
+ it('keeps every registered SKU for a pick the page does not show', () => {
367
+ // a price shown on the host page while the modal sits on another product
368
+ const other = family('other-id', 'Other', 'other-sku', 150, [style('o1', 'os1', 100), style('o2', 'os2', 100)]);
369
+ let state = viewing(populate(packNode, populate(other)), 'pack-id');
370
+ state = select(state, 'os1');
371
+ expect(additionalCost(state, 'os2')).toBe(50);
372
+ });
373
+ it('keeps every registered SKU while the viewed product is not yet registered', () => {
374
+ let state = viewing(populate(cheapPack), 'not-fetched-id');
375
+ state = select(state, 'ds1');
376
+ state = select(state, 'ds2');
377
+ expect(selected(state)).toEqual(['pack-sku']);
378
+ });
379
+ });
380
+ describe('a pack without a SKU', () => {
381
+ const state = populate({
382
+ ...packNode,
383
+ sku: null
384
+ });
385
+ it('registers only its members', () => {
386
+ expect(state.skuStyleIds['pack-sku']).toBeUndefined();
387
+ expect(state.skuPrices['pack-sku']).toBeUndefined();
388
+ expect(state.collectionSkus['pack-id']).toBeNull();
389
+ expect(state.fetchedCollectionIds).toContain('pack-id');
390
+ });
391
+ it('never puts the pack in the selection', () => {
392
+ let after = viewing(state, 'pack-id');
393
+ for (const skuId of ['s1', 's2', 'ds1', 'ds2']) after = select(after, skuId);
394
+ expect(selected(after)).toEqual(['ds1', 'ds2', 's1', 's2']);
395
+ });
396
+ });
397
+ describe('a style member without a SKU', () => {
398
+ const state = populate({
399
+ ...packNode,
400
+ fontStyles: [...packNode.fontStyles, {
401
+ id: 'f3',
402
+ sku: null
403
+ }]
404
+ });
405
+ it('is granted by the pack SKU, by style id', () => {
406
+ expect(state.skuStyleIds['pack-sku']).toContain('f3');
407
+ });
408
+ it('is selected and deselected with the pack', () => {
409
+ const withPack = select(state, 'pack-sku');
410
+ expect(isSelected('f3')(withPack)).toBe(true);
411
+ expect(isSelected('f3')(select(withPack, 'pack-sku', false))).toBe(false);
412
+ });
413
+ });
414
+ describe('deselecting via the pack button', () => {
415
+ const state = select(select(populate(packNode), 'pack-sku'), 'pack-sku', false);
416
+ it('clears the whole pack', () => {
417
+ expect(selected(state)).toEqual([]);
418
+ for (const skuId of ['s1', 's2', 'ds1', 'ds2', 'display-sku', 'f1']) {
419
+ expect(isSelected(skuId)(state)).toBe(false);
420
+ }
421
+ });
422
+ });
423
+ describe("a '%other' member", () => {
424
+ it('is ignored', () => {
425
+ const withOther = populate({
426
+ ...packNode,
427
+ members: [...packNode.members, {
428
+ __typename: '%other'
429
+ }]
430
+ });
431
+ const without = populate(packNode);
432
+ expect(withOther.skuStyleIds).toEqual(without.skuStyleIds);
433
+ expect(withOther.skuPrices).toEqual(without.skuPrices);
434
+ });
435
+ });
@@ -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,9 +10,12 @@ 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: {},
17
+ productSkuIds: {},
18
+ viewedProductId: null,
16
19
  skuPrices: {},
17
20
  fetchedCollectionIds: [],
18
21
  storeModalRoute: {