fontdue-js 3.7.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 (123) hide show
  1. package/CHANGELOG.md +6 -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__/NodePasswordFormElementIDQuery.graphql.d.ts +3 -3
  40. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +9 -9
  41. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.d.ts +25 -0
  42. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.js +108 -0
  43. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
  44. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +32 -26
  45. package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
  46. package/dist/__generated__/StoreModalCartQuery.graphql.js +37 -31
  47. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.d.ts +23 -0
  48. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.js +58 -0
  49. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +9 -1
  50. package/dist/__generated__/StoreModalIndexQuery.graphql.js +174 -110
  51. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.d.ts +33 -0
  52. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.js +119 -0
  53. package/dist/__generated__/StoreModalPack_fontPack.graphql.d.ts +45 -0
  54. package/dist/__generated__/StoreModalPack_fontPack.graphql.js +150 -0
  55. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +3 -2
  56. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +36 -19
  57. package/dist/__generated__/{StoreModalProductLicenseSelection_collection.graphql.d.ts → StoreModalProductLicenseSelection_licenseable.graphql.d.ts} +6 -6
  58. package/dist/__generated__/{StoreModalProductLicenseSelection_collection.graphql.js → StoreModalProductLicenseSelection_licenseable.graphql.js} +5 -5
  59. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  60. package/dist/__generated__/StoreModalProductQuery.graphql.js +159 -103
  61. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  62. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +184 -128
  63. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +5 -1
  64. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +248 -171
  65. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.d.ts +1 -1
  66. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.js +9 -4
  67. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.d.ts +7 -1
  68. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.js +7 -2
  69. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
  70. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +26 -20
  71. package/dist/__generated__/productState_Query.graphql.d.ts +99 -1
  72. package/dist/__generated__/productState_Query.graphql.js +43 -4
  73. package/dist/__generated__/productState_fontPack.graphql.d.ts +111 -0
  74. package/dist/__generated__/productState_fontPack.graphql.js +117 -0
  75. package/dist/__tests__/bundleSelectionOrder.test.js +2 -0
  76. package/dist/__tests__/collectionBundleSelection.test.js +2 -0
  77. package/dist/__tests__/fontPackSelection.test.js +435 -0
  78. package/dist/__tests__/licenseExclusions.test.js +2 -0
  79. package/dist/__tests__/storeModalPackButton.test.js +102 -0
  80. package/dist/__tests__/storeModalPackUnlisted.test.js +112 -0
  81. package/dist/__tests__/storeModalRoutes.test.js +9 -0
  82. package/dist/components/AddToCartBanner/index.js +1 -1
  83. package/dist/components/BuyButton/index.d.ts +29 -9
  84. package/dist/components/BuyButton/index.js +123 -26
  85. package/dist/components/BuyButton/index.server.d.ts +4 -2
  86. package/dist/components/BuyButton/index.server.js +24 -3
  87. package/dist/components/Cart/CartItem/CartItemProduct.js +3 -3
  88. package/dist/components/CollectionAa/index.js +2 -2
  89. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +1 -1
  90. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +45 -21
  91. package/dist/components/NodePasswordForm/index.d.ts +7 -4
  92. package/dist/components/NodePasswordForm/index.js +6 -6
  93. package/dist/components/Root/productState.js +57 -7
  94. package/dist/components/StoreModal/StoreModalFamily.d.ts +7 -0
  95. package/dist/components/StoreModal/StoreModalFamily.js +7 -5
  96. package/dist/components/StoreModal/StoreModalIndex.js +13 -3
  97. package/dist/components/StoreModal/StoreModalIndexEntry.d.ts +13 -0
  98. package/dist/components/StoreModal/StoreModalIndexEntry.js +43 -0
  99. package/dist/components/StoreModal/StoreModalIndexItem.js +7 -30
  100. package/dist/components/StoreModal/StoreModalIndexPackItem.d.ts +7 -0
  101. package/dist/components/StoreModal/StoreModalIndexPackItem.js +21 -0
  102. package/dist/components/StoreModal/StoreModalPack.d.ts +9 -0
  103. package/dist/components/StoreModal/StoreModalPack.js +79 -0
  104. package/dist/components/StoreModal/StoreModalPackButton.d.ts +8 -0
  105. package/dist/components/StoreModal/StoreModalPackButton.js +53 -0
  106. package/dist/components/StoreModal/StoreModalProduct.js +38 -15
  107. package/dist/components/StoreModalProductLicenseSelection/index.d.ts +3 -3
  108. package/dist/components/StoreModalProductLicenseSelection/index.js +6 -6
  109. package/dist/components/StoreModalProductSummary/index.js +1 -1
  110. package/dist/components/elements/StoreModalFamily/index.d.ts +1 -1
  111. package/dist/components/elements/StoreModalFamily/index.js +1 -1
  112. package/dist/fontdue.css +6 -0
  113. package/dist/reducer.d.ts +7 -0
  114. package/dist/reducer.js +9 -0
  115. package/dist/selection.d.ts +5 -1
  116. package/dist/selection.js +61 -16
  117. package/dist/storeModalRoutes.js +5 -4
  118. package/dist/version.js +1 -1
  119. package/package.json +1 -1
  120. package/dist/__generated__/BuyButton_collection.graphql.d.ts +0 -25
  121. package/dist/__generated__/BuyButton_collection.graphql.js +0 -49
  122. package/fontdue.css +0 -2733
  123. package/fontdue.css.map +0 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @generated SignedSource<<c00a5a25369b3afa2e42b1f47d1fa26a>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+
7
+ /* tslint:disable */
8
+ /* eslint-disable */
9
+ // @ts-nocheck
10
+
11
+ const node = function () {
12
+ var v0 = {
13
+ "alias": null,
14
+ "args": null,
15
+ "kind": "ScalarField",
16
+ "name": "id",
17
+ "storageKey": null
18
+ },
19
+ v1 = {
20
+ "alias": null,
21
+ "args": null,
22
+ "kind": "ScalarField",
23
+ "name": "name",
24
+ "storageKey": null
25
+ },
26
+ v2 = {
27
+ "alias": null,
28
+ "args": null,
29
+ "concreteType": "Sku",
30
+ "kind": "LinkedField",
31
+ "name": "sku",
32
+ "plural": false,
33
+ "selections": [v0 /*: any*/, {
34
+ "alias": null,
35
+ "args": null,
36
+ "concreteType": "Money",
37
+ "kind": "LinkedField",
38
+ "name": "price",
39
+ "plural": false,
40
+ "selections": [{
41
+ "alias": null,
42
+ "args": null,
43
+ "kind": "ScalarField",
44
+ "name": "amount",
45
+ "storageKey": null
46
+ }],
47
+ "storageKey": null
48
+ }],
49
+ "storageKey": null
50
+ },
51
+ v3 = {
52
+ "alias": null,
53
+ "args": null,
54
+ "concreteType": "FontStyle",
55
+ "kind": "LinkedField",
56
+ "name": "fontStyles",
57
+ "plural": true,
58
+ "selections": [v0 /*: any*/, v2 /*: any*/],
59
+ "storageKey": null
60
+ },
61
+ v4 = {
62
+ "alias": null,
63
+ "args": null,
64
+ "concreteType": "Bundle",
65
+ "kind": "LinkedField",
66
+ "name": "bundles",
67
+ "plural": true,
68
+ "selections": [v0 /*: any*/, v1 /*: any*/, v2 /*: any*/, v3 /*: any*/],
69
+ "storageKey": null
70
+ };
71
+ return {
72
+ "argumentDefinitions": [],
73
+ "kind": "Fragment",
74
+ "metadata": {
75
+ "mask": false
76
+ },
77
+ "name": "productState_fontPack",
78
+ "selections": [v0 /*: any*/, v1 /*: any*/, v2 /*: any*/, v3 /*: any*/, {
79
+ "alias": null,
80
+ "args": null,
81
+ "concreteType": null,
82
+ "kind": "LinkedField",
83
+ "name": "members",
84
+ "plural": true,
85
+ "selections": [{
86
+ "alias": null,
87
+ "args": null,
88
+ "kind": "ScalarField",
89
+ "name": "__typename",
90
+ "storageKey": null
91
+ }, {
92
+ "kind": "InlineFragment",
93
+ "selections": [v0 /*: any*/, v1 /*: any*/, v2 /*: any*/, v3 /*: any*/, v4 /*: any*/, {
94
+ "alias": null,
95
+ "args": [{
96
+ "kind": "Literal",
97
+ "name": "collectionTypes",
98
+ "value": ["FAMILY"]
99
+ }],
100
+ "concreteType": "FontCollection",
101
+ "kind": "LinkedField",
102
+ "name": "children",
103
+ "plural": true,
104
+ "selections": [v0 /*: any*/, v1 /*: any*/, v2 /*: any*/, v3 /*: any*/, v4 /*: any*/],
105
+ "storageKey": "children(collectionTypes:[\"FAMILY\"])"
106
+ }],
107
+ "type": "FontCollection",
108
+ "abstractKey": null
109
+ }],
110
+ "storageKey": null
111
+ }],
112
+ "type": "FontPack",
113
+ "abstractKey": null
114
+ };
115
+ }();
116
+ node.hash = "43a236a4ddb4436a51d765a07e2b0eac";
117
+ export default node;
@@ -55,6 +55,8 @@ function loadedState() {
55
55
  licenseOptions: [],
56
56
  skuStyleIds: {},
57
57
  collectionSkus: {},
58
+ productSkuIds: {},
59
+ viewedProductId: null,
58
60
  skuPrices: {},
59
61
  fetchedCollectionIds: [],
60
62
  storeModalRoute: {
@@ -28,6 +28,8 @@ function makeState() {
28
28
  licenseOptions: [],
29
29
  skuStyleIds,
30
30
  collectionSkus: {},
31
+ productSkuIds: {},
32
+ viewedProductId: null,
31
33
  skuPrices,
32
34
  fetchedCollectionIds: [],
33
35
  storeModalRoute: {
@@ -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
+ });
@@ -14,6 +14,8 @@ function makeState() {
14
14
  licenseOptions: [],
15
15
  skuStyleIds: {},
16
16
  collectionSkus: {},
17
+ productSkuIds: {},
18
+ viewedProductId: null,
17
19
  skuPrices: {},
18
20
  fetchedCollectionIds: [],
19
21
  storeModalRoute: {
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The pack's row at the top of its product page: the name is shown exactly as
4
+ // the admin entered it, the à-la-carte total is struck through with an <s>,
5
+ // and a pack without a SKU is not clickable.
6
+
7
+ import React, { act } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ const {
12
+ stateRef
13
+ } = vi.hoisted(() => ({
14
+ stateRef: {
15
+ current: null
16
+ }
17
+ }));
18
+
19
+ // The fragment reader hands back the data it was given; the redux selector
20
+ // runs against a default store's state.
21
+ vi.mock('react-relay', () => ({
22
+ graphql: () => null,
23
+ fetchQuery: vi.fn(),
24
+ useFragment: (_fragment, key) => key
25
+ }));
26
+ vi.mock('react-redux', async importOriginal => ({
27
+ ...(await importOriginal()),
28
+ useSelector: selector => selector(stateRef.current)
29
+ }));
30
+ import StoreModalPackButton from '../components/StoreModal/StoreModalPackButton.js';
31
+ import { createDefaultStore } from '../reducer.js';
32
+ const money = amount => ({
33
+ amount,
34
+ currency: 'USD'
35
+ });
36
+ const pack = function () {
37
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
38
+ return {
39
+ name: 'Pack of Basics',
40
+ sku: {
41
+ id: 'pack-sku',
42
+ price: money(15000)
43
+ },
44
+ featureStyle: null,
45
+ totalStyles: 3,
46
+ totalStylesPrice: money(30000),
47
+ ...overrides
48
+ };
49
+ };
50
+ const markup = packData => renderToStaticMarkup( /*#__PURE__*/React.createElement(StoreModalPackButton, {
51
+ pack: packData,
52
+ onSelectSku: () => {}
53
+ }));
54
+ const mount = async (packData, onSelectSku) => {
55
+ const container = document.createElement('div');
56
+ document.body.appendChild(container);
57
+ const root = createRoot(container);
58
+ await act(async () => {
59
+ root.render( /*#__PURE__*/React.createElement(StoreModalPackButton, {
60
+ pack: packData,
61
+ onSelectSku: onSelectSku
62
+ }));
63
+ });
64
+ return container.querySelector('button');
65
+ };
66
+ describe('StoreModalPackButton', () => {
67
+ beforeEach(() => {
68
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
69
+ stateRef.current = createDefaultStore(undefined).getState();
70
+ document.body.innerHTML = '';
71
+ });
72
+ it("shows the pack's name as entered, without adding a suffix", () => {
73
+ expect(markup(pack())).toContain('>Pack of Basics<');
74
+ expect(markup(pack())).not.toContain('Pack of Basics Pack');
75
+ expect(markup(pack({
76
+ name: 'Essentials'
77
+ }))).toContain('>Essentials<');
78
+ });
79
+ it('strikes through the à-la-carte total with an <s> when the pack is cheaper', () => {
80
+ expect(markup(pack())).toMatch(/<s class="store-modal__family-button__styles-price">/);
81
+ expect(markup(pack({
82
+ totalStylesPrice: money(15000)
83
+ }))).not.toContain('store-modal__family-button__styles-price');
84
+ });
85
+ it('selects the pack SKU on click', async () => {
86
+ const onSelectSku = vi.fn();
87
+ const button = await mount(pack(), onSelectSku);
88
+ expect(button.dataset.clickable).toBe('true');
89
+ await act(async () => button.click());
90
+ expect(onSelectSku).toHaveBeenCalledWith('pack-sku', true);
91
+ });
92
+ it('is not clickable without a SKU', async () => {
93
+ const onSelectSku = vi.fn();
94
+ const button = await mount(pack({
95
+ sku: null,
96
+ totalStylesPrice: null
97
+ }), onSelectSku);
98
+ expect(button.dataset.clickable).toBe('false');
99
+ await act(async () => button.click());
100
+ expect(onSelectSku).not.toHaveBeenCalled();
101
+ });
102
+ });