fontdue-js 3.5.1 → 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 (151) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -0
  3. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
  4. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +1 -1
  5. package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.d.ts +24 -0
  6. package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.js +71 -0
  7. package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.d.ts +30 -0
  8. package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.js +98 -0
  9. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  10. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  11. package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.d.ts +3 -1
  12. package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.js +1 -1
  13. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  14. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  15. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  16. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  17. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  18. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  19. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +4 -9
  20. package/dist/__generated__/StoreModalIndexQuery.graphql.js +90 -53
  21. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  22. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  23. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +4 -1
  24. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +12 -5
  25. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.d.ts +2 -1
  26. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +27 -24
  27. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +2 -19
  28. package/dist/__generated__/StoreModalProductQuery.graphql.js +143 -174
  29. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  30. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +54 -53
  31. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +38 -0
  32. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +750 -0
  33. package/dist/__generated__/StoreModalProductViewer.graphql.d.ts +34 -0
  34. package/dist/__generated__/StoreModalProductViewer.graphql.js +149 -0
  35. package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
  36. package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.js +1 -1
  37. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +3 -1
  38. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +1 -1
  39. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  40. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  41. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  42. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  43. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  44. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  45. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  46. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  47. package/dist/__generated__/useCurrentCustomerQuery.graphql.d.ts +23 -0
  48. package/dist/__generated__/{ServerConfigProviderQuery.graphql.js → useCurrentCustomerQuery.graphql.js} +24 -39
  49. package/dist/__tests__/bundleSelectionOrder.test.js +92 -0
  50. package/dist/__tests__/collectionBundleSelection.test.js +441 -1446
  51. package/dist/__tests__/customerSession.test.js +456 -0
  52. package/dist/__tests__/infiniteScroll.test.js +180 -0
  53. package/dist/__tests__/licenseExclusions.test.js +2 -1
  54. package/dist/__tests__/storeModalRoutes.test.js +118 -0
  55. package/dist/__tests__/storefrontEvents.test.js +167 -0
  56. package/dist/__tests__/stripeElements.test.js +173 -0
  57. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  58. package/dist/__tests__/typeTesterInitialSize.test.js +82 -0
  59. package/dist/__tests__/typeTesterTruncate.test.js +218 -0
  60. package/dist/auth/session.d.ts +42 -0
  61. package/dist/auth/session.js +139 -0
  62. package/dist/auth/tokenStore.d.ts +61 -0
  63. package/dist/auth/tokenStore.js +162 -0
  64. package/dist/components/Cart/Checkout.js +19 -9
  65. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  66. package/dist/components/Cart/storefrontEvents.js +138 -0
  67. package/dist/components/ConfigContext.d.ts +2 -2
  68. package/dist/components/CustomerSignIn/index.d.ts +33 -0
  69. package/dist/components/CustomerSignIn/index.js +170 -0
  70. package/dist/components/FontFamilies/index.js +4 -3
  71. package/dist/components/NewsletterSignup/index.js +5 -1
  72. package/dist/components/Precart/index.js +6 -0
  73. package/dist/components/PriceBar/index.js +5 -3
  74. package/dist/components/Root/index.js +4 -34
  75. package/dist/components/Root/productState.d.ts +2 -8
  76. package/dist/components/Root/productState.js +46 -57
  77. package/dist/components/SKUPrice/index.js +7 -8
  78. package/dist/components/StoreModal/StoreModalFamily.js +4 -2
  79. package/dist/components/StoreModal/StoreModalIndex.js +48 -7
  80. package/dist/components/StoreModal/StoreModalProduct.d.ts +5 -1
  81. package/dist/components/StoreModal/StoreModalProduct.js +69 -16
  82. package/dist/components/StoreModal/index.js +14 -14
  83. package/dist/components/StoreModal/routes.js +9 -1
  84. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  85. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  86. package/dist/components/StoreModalProductLicenseSelection/index.js +3 -2
  87. package/dist/components/TestFontsForm/index.js +5 -1
  88. package/dist/components/TypeTester/TypeTesterContent.js +9 -2
  89. package/dist/components/TypeTester/TypeTesterContext.js +17 -4
  90. package/dist/components/TypeTester/TypeTesterFitter.js +7 -2
  91. package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -0
  92. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -0
  93. package/dist/components/TypeTester/TypeTesterStandaloneElement.d.ts +1 -0
  94. package/dist/components/TypeTester/TypeTesterStandaloneElement.js +4 -1
  95. package/dist/components/TypeTester/TypeTesterVariableAxes.js +16 -10
  96. package/dist/components/TypeTester/index.d.ts +3 -1
  97. package/dist/components/TypeTester/index.js +2 -1
  98. package/dist/components/TypeTester/truncate.d.ts +12 -0
  99. package/dist/components/TypeTester/truncate.js +95 -0
  100. package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
  101. package/dist/components/TypeTester/useTypeTesterStyler.js +36 -7
  102. package/dist/components/TypeTesters/TypeTestersElement.d.ts +2 -1
  103. package/dist/components/TypeTesters/TypeTestersElement.js +4 -1
  104. package/dist/components/TypeTesters/index.d.ts +2 -0
  105. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  106. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  107. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  108. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  109. package/dist/components/elements/StoreModalUnifiedCheckout.d.ts +1 -1
  110. package/dist/components/elements/StoreModalUnifiedCheckout.js +20 -17
  111. package/dist/components/useInfiniteScroll.d.ts +28 -0
  112. package/dist/components/useInfiniteScroll.js +50 -0
  113. package/dist/fontdue.css +63 -0
  114. package/dist/hooks/useAutofit.d.ts +2 -1
  115. package/dist/hooks/useAutofit.js +10 -4
  116. package/dist/hooks/useCurrentCustomer.d.ts +34 -0
  117. package/dist/hooks/useCurrentCustomer.js +93 -0
  118. package/dist/reducer.d.ts +5 -5
  119. package/dist/reducer.js +9 -87
  120. package/dist/relay/environment.js +91 -50
  121. package/dist/selection.d.ts +40 -0
  122. package/dist/selection.js +494 -0
  123. package/dist/storeModalRoutes.d.ts +38 -0
  124. package/dist/storeModalRoutes.js +110 -0
  125. package/dist/stripeElements.d.ts +18 -0
  126. package/dist/stripeElements.js +49 -0
  127. package/dist/utils.d.ts +1 -12
  128. package/dist/utils.js +1 -36
  129. package/dist/version.js +1 -1
  130. package/package.json +1 -1
  131. package/dist/__generated__/NodePasswordFormIDQuery.graphql.d.ts +0 -21
  132. package/dist/__generated__/NodePasswordFormIDQuery.graphql.js +0 -86
  133. package/dist/__generated__/NodePasswordFormSlugQuery.graphql.d.ts +0 -25
  134. package/dist/__generated__/NodePasswordFormSlugQuery.graphql.js +0 -108
  135. package/dist/__generated__/ServerConfigProviderQuery.graphql.d.ts +0 -24
  136. package/dist/__generated__/TestModeBannerQuery.graphql.d.ts +0 -20
  137. package/dist/__generated__/TestModeBannerQuery.graphql.js +0 -87
  138. package/dist/__generated__/ThemeConfigQuery.graphql.d.ts +0 -20
  139. package/dist/__generated__/ThemeConfigQuery.graphql.js +0 -87
  140. package/dist/components/CartButton/index.server.d.ts +0 -3
  141. package/dist/components/CartButton/index.server.js +0 -18
  142. package/dist/components/CorsErrorModal.d.ts +0 -1
  143. package/dist/components/CorsErrorModal.js +0 -92
  144. package/dist/components/FontdueProvider/FontdueProviderClientComponent.d.ts +0 -14
  145. package/dist/components/FontdueProvider/FontdueProviderClientComponent.js +0 -61
  146. package/dist/components/ThemeConfig/index.server.d.ts +0 -2
  147. package/dist/components/ThemeConfig/index.server.js +0 -17
  148. package/dist/components/TypeTester/features.d.ts +0 -5
  149. package/dist/components/TypeTester/features.js +0 -143
  150. package/dist/data/unicodeData.d.ts +0 -4
  151. package/dist/data/unicodeData.js +0 -1
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { normalizeTruncate, parseTruncateAttribute, shouldTruncate } from "../components/TypeTester/truncate.js";
3
+ import { mergeConfig } from "../components/ConfigContext.js";
4
+ describe('normalizeTruncate', () => {
5
+ it('is off when unset or false', () => {
6
+ expect(normalizeTruncate()).toEqual({
7
+ lines: 0,
8
+ expandOnFocus: false
9
+ });
10
+ expect(normalizeTruncate(false)).toEqual({
11
+ lines: 0,
12
+ expandOnFocus: false
13
+ });
14
+ });
15
+ it('treats `true` as the legacy one-line preview that opens on focus', () => {
16
+ expect(normalizeTruncate(true)).toEqual({
17
+ lines: 1,
18
+ expandOnFocus: true
19
+ });
20
+ });
21
+ it('holds a numeric cap while the tester is focused', () => {
22
+ expect(normalizeTruncate(15)).toEqual({
23
+ lines: 15,
24
+ expandOnFocus: false
25
+ });
26
+ });
27
+ it('distinguishes `truncate: 1` from `truncate: true`', () => {
28
+ expect(normalizeTruncate(1).expandOnFocus).toBe(false);
29
+ expect(normalizeTruncate(true).expandOnFocus).toBe(true);
30
+ });
31
+ it('floors and clamps line counts', () => {
32
+ expect(normalizeTruncate(15.9).lines).toBe(15);
33
+ expect(normalizeTruncate(-3).lines).toBe(0);
34
+ expect(normalizeTruncate(NaN).lines).toBe(0);
35
+ expect(normalizeTruncate(Infinity).lines).toBe(0);
36
+ });
37
+ it('accepts the explicit object form', () => {
38
+ expect(normalizeTruncate({
39
+ lines: 20,
40
+ expandOnFocus: true
41
+ })).toEqual({
42
+ lines: 20,
43
+ expandOnFocus: true
44
+ });
45
+ expect(normalizeTruncate({
46
+ lines: 1
47
+ })).toEqual({
48
+ lines: 1,
49
+ expandOnFocus: false
50
+ });
51
+ expect(normalizeTruncate({
52
+ expandOnFocus: false
53
+ })).toEqual({
54
+ lines: 0,
55
+ expandOnFocus: false
56
+ });
57
+ });
58
+ });
59
+
60
+ // The one behaviour FD-191 changes. Everything else about truncation is
61
+ // geometry that only a browser can check.
62
+ describe('shouldTruncate', () => {
63
+ it('keeps a line count capped through focus', () => {
64
+ const resolved = normalizeTruncate(15);
65
+ expect(shouldTruncate(resolved, false)).toBe(true);
66
+ expect(shouldTruncate(resolved, true)).toBe(true);
67
+ });
68
+ it('still lifts the legacy one-line preview on focus', () => {
69
+ const resolved = normalizeTruncate(true);
70
+ expect(shouldTruncate(resolved, false)).toBe(true);
71
+ expect(shouldTruncate(resolved, true)).toBe(false);
72
+ });
73
+ it('lets the object form ask for the old jump-open behaviour', () => {
74
+ const resolved = normalizeTruncate({
75
+ lines: 20,
76
+ expandOnFocus: true
77
+ });
78
+ expect(shouldTruncate(resolved, false)).toBe(true);
79
+ expect(shouldTruncate(resolved, true)).toBe(false);
80
+ });
81
+ it('is off in both states when truncation is off', () => {
82
+ const resolved = normalizeTruncate(false);
83
+ expect(shouldTruncate(resolved, false)).toBe(false);
84
+ expect(shouldTruncate(resolved, true)).toBe(false);
85
+ });
86
+ });
87
+ describe('parseTruncateAttribute', () => {
88
+ afterEach(() => vi.restoreAllMocks());
89
+ it('inherits the site config when the attribute is absent', () => {
90
+ expect(parseTruncateAttribute(undefined, 'fontdue-type-tester')).toBeUndefined();
91
+ });
92
+ it('parses the written forms', () => {
93
+ expect(parseTruncateAttribute('15', 'fontdue-type-tester')).toBe(15);
94
+ expect(parseTruncateAttribute('true', 'fontdue-type-tester')).toBe(true);
95
+ expect(parseTruncateAttribute('false', 'fontdue-type-tester')).toBe(false);
96
+ expect(parseTruncateAttribute('0', 'fontdue-type-tester')).toBe(0);
97
+ });
98
+ it('warns and inherits on garbage rather than silently reading as off', () => {
99
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
100
+ expect(parseTruncateAttribute('fifteen', 'fontdue-type-tester')).toBeUndefined();
101
+ expect(parseTruncateAttribute('15.5', 'fontdue-type-tester')).toBeUndefined();
102
+ expect(parseTruncateAttribute('-2', 'fontdue-type-tester')).toBeUndefined();
103
+ expect(warn).toHaveBeenCalledTimes(3);
104
+ });
105
+
106
+ // Root turns a valueless `truncate` into '', which is an authoring mistake
107
+ // rather than a request to inherit, so it has to be audible.
108
+ it('warns on a valueless attribute', () => {
109
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
110
+ expect(parseTruncateAttribute('', 'fontdue-type-tester')).toBeUndefined();
111
+ expect(warn).toHaveBeenCalledTimes(1);
112
+ });
113
+ it('names the element it failed on', () => {
114
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
115
+ parseTruncateAttribute('nope', 'fontdue-type-testers');
116
+ expect(warn.mock.calls[0][0]).toContain('fontdue-type-testers');
117
+ });
118
+
119
+ // The second word is the only way an attribute can reach `expandOnFocus`,
120
+ // which is the one combination the scalar forms cannot express: a line count
121
+ // that still opens on click.
122
+ it('reads a second word as expandOnFocus', () => {
123
+ expect(parseTruncateAttribute('15 expandable', 'fontdue-type-tester')).toEqual({
124
+ lines: 15,
125
+ expandOnFocus: true
126
+ });
127
+ expect(parseTruncateAttribute('15 fixed', 'fontdue-type-tester')).toEqual({
128
+ lines: 15,
129
+ expandOnFocus: false
130
+ });
131
+ });
132
+ it('fills the gap the scalar forms leave', () => {
133
+ const resolved = normalizeTruncate(parseTruncateAttribute('15 expandable', 'fontdue-type-tester'));
134
+ expect(resolved).toEqual({
135
+ lines: 15,
136
+ expandOnFocus: true
137
+ });
138
+ expect(shouldTruncate(resolved, false)).toBe(true);
139
+ expect(shouldTruncate(resolved, true)).toBe(false);
140
+ });
141
+ it('takes a second word after true and false too', () => {
142
+ // `true fixed` is the one-line preview that stays put, which was previously
143
+ // only reachable as the bare count "1".
144
+ expect(parseTruncateAttribute('true fixed', 'fontdue-type-tester')).toEqual({
145
+ lines: 1,
146
+ expandOnFocus: false
147
+ });
148
+ expect(parseTruncateAttribute('true expandable', 'fontdue-type-tester')).toEqual({
149
+ lines: 1,
150
+ expandOnFocus: true
151
+ });
152
+ });
153
+ it('tolerates the whitespace a hand-written attribute picks up', () => {
154
+ expect(parseTruncateAttribute(' 15 expandable ', 'fontdue-type-tester')).toEqual({
155
+ lines: 15,
156
+ expandOnFocus: true
157
+ });
158
+ });
159
+ it('warns on an unknown or extra word rather than ignoring it', () => {
160
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
161
+ expect(parseTruncateAttribute('15 open', 'fontdue-type-tester')).toBeUndefined();
162
+ expect(parseTruncateAttribute('15 Expandable', 'fontdue-type-tester')).toBeUndefined();
163
+ expect(parseTruncateAttribute('15 expandable now', 'fontdue-type-tester')).toBeUndefined();
164
+ expect(parseTruncateAttribute('expandable', 'fontdue-type-tester')).toBeUndefined();
165
+ expect(warn).toHaveBeenCalledTimes(4);
166
+ });
167
+
168
+ // A word that is an Object member must be a typo like any other, not a
169
+ // truthy hit off the prototype chain.
170
+ it('does not treat inherited Object members as words', () => {
171
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
172
+ expect(parseTruncateAttribute('15 constructor', 'fontdue-type-tester')).toBeUndefined();
173
+ expect(warn).toHaveBeenCalledTimes(1);
174
+ });
175
+ it('tells the author which forms it accepts', () => {
176
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
177
+ parseTruncateAttribute('15 open', 'fontdue-type-tester');
178
+ expect(warn.mock.calls[0][0]).toContain('15 expandable');
179
+ });
180
+ });
181
+ describe('mergeConfig with the object truncate form', () => {
182
+ it('deep-merges a per-component line count onto a global expandOnFocus', () => {
183
+ var _merged$typeTester;
184
+ const merged = mergeConfig({
185
+ typeTester: {
186
+ truncate: {
187
+ expandOnFocus: true
188
+ }
189
+ }
190
+ }, {
191
+ typeTester: {
192
+ truncate: {
193
+ lines: 15
194
+ }
195
+ }
196
+ });
197
+ expect((_merged$typeTester = merged.typeTester) === null || _merged$typeTester === void 0 ? void 0 : _merged$typeTester.truncate).toEqual({
198
+ expandOnFocus: true,
199
+ lines: 15
200
+ });
201
+ });
202
+ it('lets a scalar override replace an object base outright', () => {
203
+ var _merged$typeTester2;
204
+ const merged = mergeConfig({
205
+ typeTester: {
206
+ truncate: {
207
+ lines: 10,
208
+ expandOnFocus: true
209
+ }
210
+ }
211
+ }, {
212
+ typeTester: {
213
+ truncate: 20
214
+ }
215
+ });
216
+ expect((_merged$typeTester2 = merged.typeTester) === null || _merged$typeTester2 === void 0 ? void 0 : _merged$typeTester2.truncate).toBe(20);
217
+ });
218
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The customer session, as the network layer sees it: putting the access token
3
+ * on a request, and renewing it when the server says it has expired.
4
+ *
5
+ * The access token lasts 24 hours and the refresh token 30 days, so a customer
6
+ * who signs in once and comes back the next week arrives with an expired access
7
+ * token and a good refresh token. That is the ordinary case, not an error —
8
+ * `token:expired` is renewed and the request retried, and the visitor never
9
+ * sees it. Only `token:invalid` (a forged, tampered or too-old credential, or
10
+ * one naming a customer who no longer exists) ends the session.
11
+ */
12
+ import { AuthTokens } from './tokenStore.js';
13
+ /** Codes `FontageWeb.UserAuthPlugs` and the RequireCustomer middleware return. */
14
+ export type CustomerAuthErrorCode = 'token:expired' | 'token:invalid' | 'token:missing' | 'customer:required';
15
+ /**
16
+ * The customer-auth error code in a GraphQL response, if it carries one.
17
+ *
18
+ * These arrive as ordinary field errors rather than a failed request — a
19
+ * storefront query asks for public content and `viewer.currentCustomer` in one
20
+ * go, and the public half still resolves.
21
+ */
22
+ export declare function customerAuthErrorCode(json: unknown): CustomerAuthErrorCode | undefined;
23
+ /**
24
+ * Puts the stored access token on `headers`, and reports whether it did.
25
+ *
26
+ * Returns false when the visitor is signed out, so the caller knows an auth
27
+ * error in the response is about somebody else's credential (an admin preview
28
+ * token, say) and none of its business.
29
+ */
30
+ export declare function applyCustomerAuthHeader(headers: Record<string, string>, scope: string | undefined): boolean;
31
+ /**
32
+ * Trades the stored refresh token for a new pair.
33
+ *
34
+ * Deliberately a plain `fetch` rather than a Relay mutation: this runs from
35
+ * inside the Relay network layer, and going back through it would recurse.
36
+ *
37
+ * Returns the new tokens, or `null` — in which case the session has been
38
+ * cleared and the caller should let the original auth error stand.
39
+ */
40
+ export declare function refreshCustomerSession(url: string, scope: string | undefined, extraHeaders?: Record<string, string>): Promise<AuthTokens | null>;
41
+ /** Test seam: forgets any refresh that is still in flight. */
42
+ export declare function resetRefreshState(): void;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The customer session, as the network layer sees it: putting the access token
3
+ * on a request, and renewing it when the server says it has expired.
4
+ *
5
+ * The access token lasts 24 hours and the refresh token 30 days, so a customer
6
+ * who signs in once and comes back the next week arrives with an expired access
7
+ * token and a good refresh token. That is the ordinary case, not an error —
8
+ * `token:expired` is renewed and the request retried, and the visitor never
9
+ * sees it. Only `token:invalid` (a forged, tampered or too-old credential, or
10
+ * one naming a customer who no longer exists) ends the session.
11
+ */
12
+
13
+ import { clearTokens, getTokens, setTokens } from './tokenStore.js';
14
+
15
+ /** Codes `FontageWeb.UserAuthPlugs` and the RequireCustomer middleware return. */
16
+
17
+ const AUTH_ERROR_CODES = ['token:expired', 'token:invalid', 'token:missing', 'customer:required'];
18
+ /**
19
+ * The customer-auth error code in a GraphQL response, if it carries one.
20
+ *
21
+ * These arrive as ordinary field errors rather than a failed request — a
22
+ * storefront query asks for public content and `viewer.currentCustomer` in one
23
+ * go, and the public half still resolves.
24
+ */
25
+ export function customerAuthErrorCode(json) {
26
+ const errors = json === null || json === void 0 ? void 0 : json.errors;
27
+ if (!Array.isArray(errors)) return undefined;
28
+ for (const error of errors) {
29
+ var _error$extensions;
30
+ const code = error === null || error === void 0 ? void 0 : (_error$extensions = error.extensions) === null || _error$extensions === void 0 ? void 0 : _error$extensions.code;
31
+ if (typeof code === 'string' && AUTH_ERROR_CODES.includes(code)) {
32
+ return code;
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /**
39
+ * Puts the stored access token on `headers`, and reports whether it did.
40
+ *
41
+ * Returns false when the visitor is signed out, so the caller knows an auth
42
+ * error in the response is about somebody else's credential (an admin preview
43
+ * token, say) and none of its business.
44
+ */
45
+ export function applyCustomerAuthHeader(headers, scope) {
46
+ const tokens = getTokens(scope);
47
+ if (tokens == null) return false;
48
+ headers.Authorization = `Bearer ${tokens.accessToken}`;
49
+ return true;
50
+ }
51
+
52
+ // Renewal is idempotent but not free, and a page load fires several queries at
53
+ // once — all of which would come back `token:expired` together. One refresh per
54
+ // scope is shared between them.
55
+ const inFlight = new Map();
56
+ const REFRESH_MUTATION = `mutation FontdueRefreshAccessToken($refreshToken: String!) {
57
+ refreshAccessToken(input: { refreshToken: $refreshToken }) {
58
+ accessToken
59
+ refreshToken
60
+ }
61
+ }`;
62
+
63
+ /**
64
+ * Trades the stored refresh token for a new pair.
65
+ *
66
+ * Deliberately a plain `fetch` rather than a Relay mutation: this runs from
67
+ * inside the Relay network layer, and going back through it would recurse.
68
+ *
69
+ * Returns the new tokens, or `null` — in which case the session has been
70
+ * cleared and the caller should let the original auth error stand.
71
+ */
72
+ export function refreshCustomerSession(url, scope) {
73
+ let extraHeaders = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
74
+ const key = scope ?? 'default';
75
+ const existing = inFlight.get(key);
76
+ if (existing) return existing;
77
+ const pending = doRefresh(url, scope, extraHeaders).finally(() => {
78
+ inFlight.delete(key);
79
+ });
80
+ inFlight.set(key, pending);
81
+ return pending;
82
+ }
83
+ async function doRefresh(url, scope, extraHeaders) {
84
+ const tokens = getTokens(scope);
85
+ if (tokens == null) return null;
86
+
87
+ // The caller's headers carry the tenant-routing and telemetry headers this
88
+ // request wants too — but not its `Authorization`, which by now holds the
89
+ // very access token the server has just refused. The refresh token travels in
90
+ // the mutation's variables instead.
91
+ const {
92
+ Authorization: _access,
93
+ authorization: _lower,
94
+ ...forwarded
95
+ } = extraHeaders;
96
+ try {
97
+ var _json$data;
98
+ const response = await fetch(`${url}?queryName=FontdueRefreshAccessToken`, {
99
+ method: 'POST',
100
+ credentials: 'include',
101
+ headers: {
102
+ ...forwarded,
103
+ Accept: 'application/json',
104
+ 'Content-Type': 'application/json'
105
+ },
106
+ body: JSON.stringify({
107
+ query: REFRESH_MUTATION,
108
+ variables: {
109
+ refreshToken: tokens.refreshToken
110
+ }
111
+ })
112
+ });
113
+ if (!response.ok) return null;
114
+ const json = await response.json();
115
+ const renewed = json === null || json === void 0 ? void 0 : (_json$data = json.data) === null || _json$data === void 0 ? void 0 : _json$data.refreshAccessToken;
116
+ if (typeof (renewed === null || renewed === void 0 ? void 0 : renewed.accessToken) === 'string' && typeof (renewed === null || renewed === void 0 ? void 0 : renewed.refreshToken) === 'string') {
117
+ const next = {
118
+ accessToken: renewed.accessToken,
119
+ refreshToken: renewed.refreshToken
120
+ };
121
+ setTokens(next, scope);
122
+ return next;
123
+ }
124
+
125
+ // The server answered, and it will not renew this credential: the refresh
126
+ // token is expired or invalid too. Sign out rather than retrying forever.
127
+ clearTokens(scope);
128
+ return null;
129
+ } catch {
130
+ // A network failure says nothing about the credential. Keep the tokens and
131
+ // let the original error surface — the next request will try again.
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** Test seam: forgets any refresh that is still in flight. */
137
+ export function resetRefreshState() {
138
+ inFlight.clear();
139
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Where a signed-in customer's tokens live in the browser.
3
+ *
4
+ * Sign-in returns an access token (24 hours) and a refresh token (30 days).
5
+ * Both are bearer credentials the network layer has to put on an
6
+ * `Authorization` header, so they have to be readable by JavaScript — an
7
+ * httpOnly cookie, safer for a session the server reads, cannot be read back to
8
+ * build that header. `localStorage` is therefore no weaker here than a
9
+ * JS-readable cookie, and it does not ride along on every request to the
10
+ * foundry's own site.
11
+ *
12
+ * ## Why a module singleton, and why on globalThis
13
+ *
14
+ * Astro (and Vike, and Qwik) mount one React tree per island, each with its own
15
+ * reconciler and its own copy of every context — so a token held in React state
16
+ * would have the sign-in island signed in and the cart island signed out. Next's
17
+ * server build can also bundle one file into several chunks, whose module-level
18
+ * `let`s are separate variables. Anchoring the singleton on `globalThis` covers
19
+ * both: every island and every duplicated copy of this module reads one store.
20
+ * Same reasoning as `relay/serverConfig.ts` and the Relay environment.
21
+ *
22
+ * ## Scope
23
+ *
24
+ * Keyed by the Fontdue URL the tokens came from, so a page wired to two
25
+ * foundries — or a developer switching a local site between them — never sends
26
+ * one foundry's token to the other.
27
+ *
28
+ * ## SSR
29
+ *
30
+ * Every entry point returns as if signed out on the server. There is no browser
31
+ * storage to read there, and reading a *shared* store on the server would leak
32
+ * one visitor's session into another's render. The first client render
33
+ * therefore matches the server HTML, and the tokens appear on the render after
34
+ * hydration, driven by `subscribeToTokens`.
35
+ */
36
+ export interface AuthTokens {
37
+ accessToken: string;
38
+ refreshToken: string;
39
+ }
40
+ /**
41
+ * The `localStorage` key for a given Fontdue URL.
42
+ *
43
+ * Exported so a host app can clear it, and so the tests can assert on the exact
44
+ * key rather than on whatever the implementation happens to write.
45
+ */
46
+ export declare function tokenStorageKey(url?: string): string;
47
+ /** The tokens held for `url`, or `null` when signed out (always on the server). */
48
+ export declare function getTokens(url?: string): AuthTokens | null;
49
+ /** Replaces the tokens held for `url`, or clears them when given `null`. */
50
+ export declare function setTokens(tokens: AuthTokens | null, url?: string): void;
51
+ /** Signs out of `url`: forgets the tokens here and in storage. */
52
+ export declare function clearTokens(url?: string): void;
53
+ /**
54
+ * Calls `listener` whenever the tokens change, and returns an unsubscribe.
55
+ *
56
+ * This is how a sign-in in one island reaches a `useCurrentCustomer()` in
57
+ * another — nothing else connects two React trees on the same page.
58
+ */
59
+ export declare function subscribeToTokens(listener: () => void): () => void;
60
+ /** Test seam: drops every cached token and listener. */
61
+ export declare function resetTokenStore(): void;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Where a signed-in customer's tokens live in the browser.
3
+ *
4
+ * Sign-in returns an access token (24 hours) and a refresh token (30 days).
5
+ * Both are bearer credentials the network layer has to put on an
6
+ * `Authorization` header, so they have to be readable by JavaScript — an
7
+ * httpOnly cookie, safer for a session the server reads, cannot be read back to
8
+ * build that header. `localStorage` is therefore no weaker here than a
9
+ * JS-readable cookie, and it does not ride along on every request to the
10
+ * foundry's own site.
11
+ *
12
+ * ## Why a module singleton, and why on globalThis
13
+ *
14
+ * Astro (and Vike, and Qwik) mount one React tree per island, each with its own
15
+ * reconciler and its own copy of every context — so a token held in React state
16
+ * would have the sign-in island signed in and the cart island signed out. Next's
17
+ * server build can also bundle one file into several chunks, whose module-level
18
+ * `let`s are separate variables. Anchoring the singleton on `globalThis` covers
19
+ * both: every island and every duplicated copy of this module reads one store.
20
+ * Same reasoning as `relay/serverConfig.ts` and the Relay environment.
21
+ *
22
+ * ## Scope
23
+ *
24
+ * Keyed by the Fontdue URL the tokens came from, so a page wired to two
25
+ * foundries — or a developer switching a local site between them — never sends
26
+ * one foundry's token to the other.
27
+ *
28
+ * ## SSR
29
+ *
30
+ * Every entry point returns as if signed out on the server. There is no browser
31
+ * storage to read there, and reading a *shared* store on the server would leak
32
+ * one visitor's session into another's render. The first client render
33
+ * therefore matches the server HTML, and the tokens appear on the render after
34
+ * hydration, driven by `subscribeToTokens`.
35
+ */
36
+
37
+ const IS_SERVER = typeof window === 'undefined';
38
+ const STORE_KEY = '__fontdueAuthTokenStore__';
39
+ const STORAGE_PREFIX = 'fontdue:auth:';
40
+ function store() {
41
+ const g = globalThis;
42
+ let s = g[STORE_KEY];
43
+ if (!s) {
44
+ s = {
45
+ cache: new Map(),
46
+ listeners: new Set()
47
+ };
48
+ g[STORE_KEY] = s;
49
+ }
50
+ return s;
51
+ }
52
+
53
+ /**
54
+ * The `localStorage` key for a given Fontdue URL.
55
+ *
56
+ * Exported so a host app can clear it, and so the tests can assert on the exact
57
+ * key rather than on whatever the implementation happens to write.
58
+ */
59
+ export function tokenStorageKey(url) {
60
+ return STORAGE_PREFIX + scope(url);
61
+ }
62
+
63
+ // A trailing slash and a case difference are the same origin as far as the API
64
+ // is concerned, so they must not be two different sessions.
65
+ function scope(url) {
66
+ if (url == null || url === '') return 'default';
67
+ return url.trim().replace(/\/+$/, '').toLowerCase();
68
+ }
69
+
70
+ // Storage throws rather than returning null in more situations than people
71
+ // expect: Safari's private mode historically, a browser set to block site data,
72
+ // and any cross-origin iframe with third-party storage partitioned off. None of
73
+ // those should take the page down — a visitor who can't persist a session
74
+ // simply signs in again.
75
+ function readStorage(key) {
76
+ try {
77
+ const raw = window.localStorage.getItem(key);
78
+ if (!raw) return null;
79
+ const parsed = JSON.parse(raw);
80
+ if (typeof (parsed === null || parsed === void 0 ? void 0 : parsed.accessToken) !== 'string' || typeof (parsed === null || parsed === void 0 ? void 0 : parsed.refreshToken) !== 'string') {
81
+ return null;
82
+ }
83
+ return {
84
+ accessToken: parsed.accessToken,
85
+ refreshToken: parsed.refreshToken
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+ function writeStorage(key, tokens) {
92
+ try {
93
+ if (tokens == null) {
94
+ window.localStorage.removeItem(key);
95
+ } else {
96
+ window.localStorage.setItem(key, JSON.stringify(tokens));
97
+ }
98
+ } catch {
99
+ // Kept in memory only. The session lasts the tab rather than the month.
100
+ }
101
+ }
102
+
103
+ /** The tokens held for `url`, or `null` when signed out (always on the server). */
104
+ export function getTokens(url) {
105
+ if (IS_SERVER) return null;
106
+ const {
107
+ cache
108
+ } = store();
109
+ const key = scope(url);
110
+ if (!cache.has(key)) {
111
+ cache.set(key, readStorage(tokenStorageKey(url)));
112
+ }
113
+ return cache.get(key) ?? null;
114
+ }
115
+
116
+ /** Replaces the tokens held for `url`, or clears them when given `null`. */
117
+ export function setTokens(tokens, url) {
118
+ if (IS_SERVER) return;
119
+ const s = store();
120
+ s.cache.set(scope(url), tokens);
121
+ writeStorage(tokenStorageKey(url), tokens);
122
+ notify(s);
123
+ }
124
+
125
+ /** Signs out of `url`: forgets the tokens here and in storage. */
126
+ export function clearTokens(url) {
127
+ setTokens(null, url);
128
+ }
129
+
130
+ /**
131
+ * Calls `listener` whenever the tokens change, and returns an unsubscribe.
132
+ *
133
+ * This is how a sign-in in one island reaches a `useCurrentCustomer()` in
134
+ * another — nothing else connects two React trees on the same page.
135
+ */
136
+ export function subscribeToTokens(listener) {
137
+ if (IS_SERVER) return () => {};
138
+ const {
139
+ listeners
140
+ } = store();
141
+ listeners.add(listener);
142
+ return () => {
143
+ listeners.delete(listener);
144
+ };
145
+ }
146
+ function notify(s) {
147
+ // A copy, so a listener that unsubscribes itself while being called doesn't
148
+ // change the set mid-iteration.
149
+ for (const listener of Array.from(s.listeners)) {
150
+ try {
151
+ listener();
152
+ } catch {
153
+ // One broken subscriber must not stop the others hearing about a sign-out.
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Test seam: drops every cached token and listener. */
159
+ export function resetTokenStore() {
160
+ const g = globalThis;
161
+ delete g[STORE_KEY];
162
+ }
@@ -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';
@@ -14,6 +15,7 @@ import CheckoutSteps from './CheckoutSteps.js';
14
15
  import { useElements, useStripe } from '@stripe/react-stripe-js';
15
16
  import ConfigContext from '../ConfigContext.js';
16
17
  import { parseCustomProperties } from '../../utils.js';
18
+ import { validateAddressElements } from '../../stripeElements.js';
17
19
  const MOBILE = 'mobile';
18
20
  const DESKTOP = 'desktop';
19
21
 
@@ -98,6 +100,7 @@ const Checkout = _ref => {
98
100
  onScrollToTop();
99
101
  }, [checkoutStep]);
100
102
  const environment = useRelayEnvironment();
103
+ const paymentInfoTracked = useRef(false);
101
104
  const [errorsObject, setErrorsObject] = useState(null);
102
105
  const [customerErrorsObject, setCustomerErrorsObject] = useState(null);
103
106
  const onCompleted = (res, errors, onSuccess) => {
@@ -153,14 +156,13 @@ const Checkout = _ref => {
153
156
  elements = useElements();
154
157
  }
155
158
  const handleSubmit = async () => {
156
- // Validate Stripe elements first (for Billing and Licensee steps)
157
- if ((checkoutStep === CheckoutSteps.Billing || checkoutStep === CheckoutSteps.Licensee && !licenseeIsBillingIdentity) && elements) {
158
- const result = await elements.submit();
159
- if (result.error) {
160
- // Stripe will show validation errors in the UI
161
- onScrollToTop();
162
- return;
163
- }
159
+ // Validate the Stripe address first (for Billing and Licensee steps). Not
160
+ // `elements.submit()`: the order may not have a PaymentIntent yet, and
161
+ // without one Stripe.js throws.
162
+ if ((checkoutStep === CheckoutSteps.Billing || checkoutStep === CheckoutSteps.Licensee && !licenseeIsBillingIdentity) && !(await validateAddressElements(elements))) {
163
+ // Stripe shows the validation errors in the UI
164
+ onScrollToTop();
165
+ return;
164
166
  }
165
167
  setSubmitting(true);
166
168
  setError(null);
@@ -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]));