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
package/dist/fontdue.css CHANGED
@@ -89,6 +89,33 @@
89
89
  font-size: var(--cart_icon_size, 26px);
90
90
  }
91
91
 
92
+ .customer-sign-in__errors {
93
+ color: var(--error_color);
94
+ margin-top: 30px;
95
+ margin-bottom: 30px;
96
+ }
97
+ .customer-sign-in__intro {
98
+ margin-bottom: 30px;
99
+ }
100
+ .customer-sign-in__section {
101
+ margin-bottom: 30px;
102
+ }
103
+ .customer-sign-in__actions {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 1em;
107
+ flex-wrap: wrap;
108
+ }
109
+ .customer-sign-in__back {
110
+ background: none;
111
+ border: 0;
112
+ padding: 0;
113
+ color: inherit;
114
+ font: inherit;
115
+ cursor: pointer;
116
+ text-decoration: underline;
117
+ }
118
+
92
119
  .icon {
93
120
  display: inline-flex;
94
121
  justify-content: center;
@@ -860,6 +887,9 @@ fontdue-type-tester {
860
887
  position: relative;
861
888
  z-index: 0;
862
889
  }
890
+ .type-tester__text__container[data-truncated] {
891
+ overflow: hidden;
892
+ }
863
893
  .type-tester__text__container [data-contents=true] {
864
894
  column-count: var(--type-tester--column-count, auto);
865
895
  column-width: var(--type-tester--column-width, auto);
@@ -2008,6 +2038,39 @@ input:checked + .checkbox__icon {
2008
2038
  font-size: 20px;
2009
2039
  }
2010
2040
 
2041
+ .store-modal__index__load-more {
2042
+ grid-column: 1/-1;
2043
+ padding-bottom: 20px;
2044
+ text-align: center;
2045
+ }
2046
+
2047
+ .store-modal__index__load-more__button {
2048
+ background: none;
2049
+ color: inherit;
2050
+ font: inherit;
2051
+ text-decoration: inherit;
2052
+ text-align: inherit;
2053
+ border: 0;
2054
+ border-radius: 0;
2055
+ padding: 0;
2056
+ margin: 0;
2057
+ appearance: none;
2058
+ text-transform: inherit;
2059
+ }
2060
+ .store-modal__index__load-more__button:active, .store-modal__index__load-more__button:focus {
2061
+ outline: none;
2062
+ }
2063
+ .store-modal__index__load-more__button:not(:disabled) {
2064
+ cursor: pointer;
2065
+ }
2066
+ .store-modal__index__load-more__button {
2067
+ color: var(--secondary_text_color);
2068
+ text-decoration: underline;
2069
+ }
2070
+ .store-modal__index__load-more__button:disabled {
2071
+ text-decoration: none;
2072
+ }
2073
+
2011
2074
  .store-modal__page__body:has(> .store-modal__index-item__button) {
2012
2075
  display: grid;
2013
2076
  grid-template-columns: auto 1fr auto;
@@ -8,6 +8,7 @@ export interface UseAutofitProps {
8
8
  fontWeight?: React.CSSProperties['fontWeight'];
9
9
  fontStyle?: string;
10
10
  fontVariationSettings?: string;
11
+ fontFeatureSettings?: string;
11
12
  enabled?: boolean;
12
13
  padding?: number;
13
14
  }
@@ -16,5 +17,5 @@ export interface UseAutofitResult {
16
17
  fontSize: number;
17
18
  ready: boolean;
18
19
  }
19
- declare const useAutofit: ({ text, fontFamily, fontSize: externalFontSize, min, max, letterSpacing, fontWeight, fontStyle, fontVariationSettings, enabled, padding, }: UseAutofitProps) => UseAutofitResult;
20
+ declare const useAutofit: ({ text, fontFamily, fontSize: externalFontSize, min, max, letterSpacing, fontWeight, fontStyle, fontVariationSettings, fontFeatureSettings, enabled, padding, }: UseAutofitProps) => UseAutofitResult;
20
21
  export default useAutofit;
@@ -9,7 +9,8 @@ function getCanvasContext() {
9
9
  return sharedCanvas.getContext('2d');
10
10
  }
11
11
 
12
- // DOM measurement (fallback for variable fonts)
12
+ // DOM measurement (fallback for variable fonts and OpenType features, which
13
+ // canvas can't express: `ctx.font` takes neither variation nor feature settings)
13
14
  let measureEl = null;
14
15
  function getMeasureElement() {
15
16
  if (!measureEl) {
@@ -31,7 +32,7 @@ function measureWithCanvas(text, fontFamily, fontWeight, fontStyleValue, letterS
31
32
  ctx.letterSpacing = '0px';
32
33
  return width;
33
34
  }
34
- function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpacing, fontVariationSettings) {
35
+ function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpacing, fontVariationSettings, fontFeatureSettings) {
35
36
  const el = getMeasureElement();
36
37
  el.style.fontFamily = fontFamily;
37
38
  el.style.fontWeight = `${fontWeight}`;
@@ -39,6 +40,7 @@ function measureWithDOM(text, fontFamily, fontWeight, fontStyleValue, letterSpac
39
40
  el.style.fontSize = `${REF_SIZE}px`;
40
41
  el.style.letterSpacing = `${letterSpacing}em`;
41
42
  el.style.fontVariationSettings = fontVariationSettings;
43
+ el.style.fontFeatureSettings = fontFeatureSettings;
42
44
  el.textContent = text;
43
45
  return el.getBoundingClientRect().width;
44
46
  }
@@ -53,6 +55,7 @@ const useAutofit = _ref => {
53
55
  fontWeight = 400,
54
56
  fontStyle = 'normal',
55
57
  fontVariationSettings,
58
+ fontFeatureSettings,
56
59
  enabled = true,
57
60
  padding = 0
58
61
  } = _ref;
@@ -83,7 +86,10 @@ const useAutofit = _ref => {
83
86
  // Split by newlines and measure the widest line, since the rendered
84
87
  // text preserves line breaks (Draft.js uses white-space: pre-wrap).
85
88
  const lines = text.split('\n');
86
- const measure = fontVariationSettings ? line => measureWithDOM(line, fontFamily, fontWeight, fontStyle, letterSpacing, fontVariationSettings) : line => measureWithCanvas(line, fontFamily, fontWeight, fontStyle, letterSpacing);
89
+ // Enabled features can change glyph widths (stylistic sets, small caps,
90
+ // ligatures), so the text has to be measured with them applied. The
91
+ // measure element is shared, so both settings are written on every call.
92
+ const measure = fontVariationSettings || fontFeatureSettings ? line => measureWithDOM(line, fontFamily, fontWeight, fontStyle, letterSpacing, fontVariationSettings || 'normal', fontFeatureSettings || 'normal') : line => measureWithCanvas(line, fontFamily, fontWeight, fontStyle, letterSpacing);
87
93
  const refWidth = Math.max(...lines.map(measure));
88
94
  if (refWidth <= 0) return;
89
95
 
@@ -92,7 +98,7 @@ const useAutofit = _ref => {
92
98
  const newSize = Math.min(Math.max(min, Math.floor(availableWidth / (refWidth / REF_SIZE))), max);
93
99
  setFontSize(newSize);
94
100
  if (!measured) setMeasured(true);
95
- }, [text, fontFamily, fontWeight, fontStyle, fontVariationSettings, letterSpacing, containerWidth, enabled, min, max, padding, fontVersion]);
101
+ }, [text, fontFamily, fontWeight, fontStyle, fontVariationSettings, fontFeatureSettings, letterSpacing, containerWidth, enabled, min, max, padding, fontVersion]);
96
102
  if (!enabled) return {
97
103
  ref,
98
104
  fontSize: externalFontSize,
@@ -0,0 +1,34 @@
1
+ import { useCurrentCustomerQuery$data } from '../__generated__/useCurrentCustomerQuery.graphql.js';
2
+ export type CurrentCustomer = NonNullable<NonNullable<useCurrentCustomerQuery$data['viewer']>['currentCustomer']>;
3
+ export interface UseCurrentCustomer {
4
+ /** The signed-in customer, or `null` when signed out or still loading. */
5
+ customer: CurrentCustomer | null;
6
+ /** True while the customer is being fetched. Always false when signed out. */
7
+ loading: boolean;
8
+ /** Forgets the session in this browser. Every Fontdue component sees it. */
9
+ signOut: () => void;
10
+ }
11
+ /**
12
+ * Who is signed in, if anyone.
13
+ *
14
+ * ```tsx
15
+ * const { customer, loading, signOut } = useCurrentCustomer();
16
+ *
17
+ * if (loading) return null;
18
+ * if (!customer) return <CustomerSignIn />;
19
+ * return <button onClick={signOut}>Sign out, {customer.name}</button>;
20
+ * ```
21
+ *
22
+ * Deliberately does not suspend, so it can be called from a component that
23
+ * renders on the server: it reports "signed out, not loading" during SSR and on
24
+ * the first client render, then fetches. That also means it needs no Suspense
25
+ * boundary, unlike the components that use `useLazyLoadQuery`.
26
+ *
27
+ * The signed-in state comes from the token store, which is one module-level
28
+ * singleton for the whole page — so a sign-in in one Astro island updates a
29
+ * `useCurrentCustomer()` in another, and `signOut()` signs out everywhere.
30
+ *
31
+ * Requires a `<FontdueProvider>` (or `<FontdueContextProvider>`) ancestor for
32
+ * the Relay environment.
33
+ */
34
+ export default function useCurrentCustomer(): UseCurrentCustomer;
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import _useCurrentCustomerQuery from "../__generated__/useCurrentCustomerQuery.graphql.js";
4
+ import { useCallback, useEffect, useState } from 'react';
5
+ import { fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
6
+ import { clearTokens, getTokens, subscribeToTokens } from '../auth/tokenStore.js';
7
+ import { useFontdueUrl } from '../components/UrlContext.js';
8
+ const query = (_useCurrentCustomerQuery.hash && _useCurrentCustomerQuery.hash !== "bbd9b896d31a326af6b8525fdd4464c0" && console.error("The definition of 'useCurrentCustomerQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _useCurrentCustomerQuery);
9
+ /**
10
+ * Who is signed in, if anyone.
11
+ *
12
+ * ```tsx
13
+ * const { customer, loading, signOut } = useCurrentCustomer();
14
+ *
15
+ * if (loading) return null;
16
+ * if (!customer) return <CustomerSignIn />;
17
+ * return <button onClick={signOut}>Sign out, {customer.name}</button>;
18
+ * ```
19
+ *
20
+ * Deliberately does not suspend, so it can be called from a component that
21
+ * renders on the server: it reports "signed out, not loading" during SSR and on
22
+ * the first client render, then fetches. That also means it needs no Suspense
23
+ * boundary, unlike the components that use `useLazyLoadQuery`.
24
+ *
25
+ * The signed-in state comes from the token store, which is one module-level
26
+ * singleton for the whole page — so a sign-in in one Astro island updates a
27
+ * `useCurrentCustomer()` in another, and `signOut()` signs out everywhere.
28
+ *
29
+ * Requires a `<FontdueProvider>` (or `<FontdueContextProvider>`) ancestor for
30
+ * the Relay environment.
31
+ */
32
+ export default function useCurrentCustomer() {
33
+ const environment = useRelayEnvironment();
34
+ const url = useFontdueUrl();
35
+ const [signedIn, setSignedIn] = useState(false);
36
+ const [customer, setCustomer] = useState(null);
37
+ const [loading, setLoading] = useState(false);
38
+
39
+ // Read the store in an effect rather than during render: reading it during
40
+ // render would have the first client render disagree with the server HTML for
41
+ // a signed-in visitor, which React reports as a hydration mismatch.
42
+ useEffect(() => {
43
+ const read = () => setSignedIn(getTokens(url) != null);
44
+ read();
45
+ return subscribeToTokens(read);
46
+ }, [url]);
47
+ useEffect(() => {
48
+ if (!signedIn) {
49
+ setCustomer(null);
50
+ setLoading(false);
51
+ return;
52
+ }
53
+ let cancelled = false;
54
+ setLoading(true);
55
+
56
+ // network-only: the point of this query is who is signed in *now*, and a
57
+ // store hit would answer with the previous customer after a sign-out and
58
+ // back in.
59
+ const subscription = fetchQuery(environment, query, {}, {
60
+ fetchPolicy: 'network-only'
61
+ }).subscribe({
62
+ next: data => {
63
+ var _data$viewer;
64
+ if (!cancelled) setCustomer(((_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : _data$viewer.currentCustomer) ?? null);
65
+ },
66
+ complete: () => {
67
+ if (!cancelled) setLoading(false);
68
+ },
69
+ error: () => {
70
+ // The network layer has already dealt with an expired or invalid
71
+ // token — renewed it, or cleared the session, which re-runs this
72
+ // effect. Anything left is a failed request, and reporting "signed
73
+ // out" for it is better than a spinner that never stops.
74
+ if (!cancelled) {
75
+ setCustomer(null);
76
+ setLoading(false);
77
+ }
78
+ }
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ subscription.unsubscribe();
83
+ };
84
+ }, [signedIn, environment]);
85
+ const signOut = useCallback(() => {
86
+ clearTokens(url);
87
+ }, [url]);
88
+ return {
89
+ customer,
90
+ loading,
91
+ signOut
92
+ };
93
+ }
package/dist/reducer.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { CollectionStyleSkusMap } from './components/Root/productState.js';
1
+ import { SkuStyleIds } from './components/Root/productState.js';
2
2
  import { Reducer } from 'redux';
3
3
  import { productState_Query$data } from './__generated__/productState_Query.graphql.js';
4
4
  import type { RouteConfig } from './components/StoreModal/routes.js';
5
- import { collectionSkuIdsDifferences, isSelected } from './utils.js';
5
+ import { isSelected } from './selection.js';
6
6
  import { Config } from './components/ConfigContext.js';
7
- export { collectionSkuIdsDifferences, isSelected };
7
+ export { isSelected };
8
8
  interface LicenseOptionSpec {
9
9
  licenseId: string;
10
10
  licenseOptions: {
@@ -22,8 +22,9 @@ export interface FontdueState {
22
22
  selectedSkuIds: {
23
23
  [skuId: string]: boolean;
24
24
  };
25
+ requestedSkuIds: string[];
25
26
  licenseOptions: LicenseOptionSpec[];
26
- collectionStyleSkus: CollectionStyleSkusMap;
27
+ skuStyleIds: SkuStyleIds;
27
28
  collectionSkus: {
28
29
  [collectionSkuId: string]: string | null;
29
30
  };
@@ -94,7 +95,6 @@ export type FontdueAction = {
94
95
  type: 'UPDATE_LICENSEE_IS_BILLING_IDENTITY';
95
96
  value: boolean;
96
97
  };
97
- export declare const collectionSkuIdWithDiscount: (state: FontdueState, skuId: string) => [string, number] | [null, null];
98
98
  declare const reducer: Reducer<FontdueState, FontdueAction>;
99
99
  export declare function createDefaultStore(config: Config | undefined): import("redux").Store<FontdueState, FontdueAction, unknown>;
100
100
  export default reducer;
package/dist/reducer.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { populateProductStateReducer } from './components/Root/productState.js';
2
2
  import { createStore } from 'redux';
3
3
  import { shallowEqual } from 'react-redux';
4
- import { collectionSkuIdsDifferences, collTransitivelyContainsSkuId, isSelected } from './utils.js';
4
+ import { isSelected, repriceSelection, selectSku } from './selection.js';
5
5
  import { makeConfig } from './components/ConfigContext.js';
6
- export { collectionSkuIdsDifferences, isSelected };
6
+ export { isSelected };
7
7
  const unselectLicense = (state, action) => {
8
8
  if (!(action.type === 'PRECART_UNSELECT_LICENSE' || action.type === 'PRECART_TOGGLE_LICENSE')) return state;
9
9
  return {
@@ -61,96 +61,17 @@ const selectLicenseOption = (state, action) => {
61
61
  })
62
62
  };
63
63
  };
64
- export const collectionSkuIdWithDiscount = (state, skuId) => {
65
- const differences = collectionSkuIdsDifferences(state, skuId);
66
- const collectionId = Object.keys(differences).filter(collSkuId => differences[collSkuId] <= state.skuPrices[skuId]).reduce((smallest, key) => {
67
- if (!smallest || differences[key] < differences[smallest]) return key;
68
- if (differences[key] === differences[smallest]) {
69
- // When differences are equal, prefer a collection that is not the
70
- // SKU being selected — this ensures collection bundles are preferred
71
- // over selecting the family itself at the same price
72
- if (smallest === skuId && key !== skuId) return key;
73
- // Among non-self candidates, prefer the one covering more unique
74
- // font styles — this ensures the most comprehensive collection wins
75
- if (smallest !== skuId && key !== skuId) {
76
- var _state$collectionStyl, _state$collectionStyl2;
77
- const smallestStyles = new Set((_state$collectionStyl = state.collectionStyleSkus[smallest]) === null || _state$collectionStyl === void 0 ? void 0 : _state$collectionStyl.fontStyleSkuIds).size;
78
- const keyStyles = new Set((_state$collectionStyl2 = state.collectionStyleSkus[key]) === null || _state$collectionStyl2 === void 0 ? void 0 : _state$collectionStyl2.fontStyleSkuIds).size;
79
- if (keyStyles > smallestStyles) return key;
80
- }
81
- }
82
- return smallest;
83
- }, null);
84
- if (collectionId) return [collectionId, differences[collectionId]];
85
- return [null, null];
86
- };
87
- const selectedCollectionSkuIdContainingSkuId = (state, skuId) => Object.keys(state.selectedSkuIds).filter(id => state.selectedSkuIds[id] === true).find(selectedSkuId => collTransitivelyContainsSkuId(state.collectionStyleSkus, selectedSkuId, skuId));
88
- const setKeysFalse = (acc, key) => {
89
- acc[key] = false;
90
- return acc;
91
- };
92
- const unselectedMembersSkuIds = (state, action) => {
93
- if (action.type !== 'SELECT_SKU_ID') throw new Error('Wrong action ${action.type}');
94
- const {
95
- skuId,
96
- selected
97
- } = action;
98
- if (!selected) return {}; // only do this in case we are selecting something
99
-
100
- // Recursively collect all descendant SKU IDs so that transitive
101
- // selections (e.g., collection bundle → family → bundle) are all
102
- // marked as false
103
- const result = {};
104
- const collectMembers = collSkuId => {
105
- const members = state.collectionStyleSkus[collSkuId];
106
- if (!members) return;
107
- for (const id of members.fontStyleSkuIds) {
108
- result[id] = false;
109
- }
110
- for (const id of members.childrenSkuIds) {
111
- result[id] = false;
112
- collectMembers(id);
113
- }
114
- };
115
- collectMembers(skuId);
116
- return result;
117
- };
118
64
  const selectSkuId = (state, action) => {
119
65
  if (action.type !== 'SELECT_SKU_ID') return state;
120
- const {
121
- skuId,
122
- selected
123
- } = action;
124
- let actualSkuId = skuId;
125
- if (selected) {
126
- // check if selecting any collections instead of this sku
127
- // would reduce the selection price
128
- const [collectionSkuId, _amount] = collectionSkuIdWithDiscount(state, skuId);
129
- actualSkuId = collectionSkuId || actualSkuId;
130
- } else if (state.selectedSkuIds[skuId] === false) {
131
- // we're clicking "deselect" on an item that's not actually selected,
132
- // it's a member of a collection which is selected. in this case
133
- // we'll find the collection and deselect that collection instead
134
- const parentId = selectedCollectionSkuIdContainingSkuId(state, skuId);
135
- if (parentId) actualSkuId = parentId;
136
- }
137
66
  return {
138
- ...state,
139
- precartOpen: true,
140
- selectedSkuIds: {
141
- ...state.selectedSkuIds,
142
- [actualSkuId]: selected,
143
- ...unselectedMembersSkuIds(state, {
144
- ...action,
145
- skuId: actualSkuId,
146
- selected
147
- })
148
- }
67
+ ...selectSku(state, action.skuId, action.selected),
68
+ precartOpen: true
149
69
  };
150
70
  };
151
71
  const unselectSkus = state => ({
152
72
  ...state,
153
- selectedSkuIds: {}
73
+ selectedSkuIds: {},
74
+ requestedSkuIds: []
154
75
  });
155
76
  const storeModalNavigate = (state, action) => {
156
77
  if (action.type !== 'STORE_MODAL_NAVIGATE') return state;
@@ -197,7 +118,7 @@ const reducer = (state, action) => {
197
118
  if (!state) throw 'Reducer called without state';
198
119
  switch (action.type) {
199
120
  case 'POPULATE_PRODUCT_STATE':
200
- return populateProductStateReducer(state, action);
121
+ return repriceSelection(populateProductStateReducer(state, action));
201
122
  case 'PRECART_TOGGLE_LICENSE':
202
123
  return toggleLicense(state, action);
203
124
  case 'PRECART_UNSELECT_LICENSE':
@@ -260,10 +181,11 @@ export function createDefaultStore(config) {
260
181
  return createStore(reducer, {
261
182
  selectedSkuIds: {},
262
183
  // { [skuId]: Boolean }
184
+ requestedSkuIds: [],
263
185
  licenseOptions: [],
264
186
  cartOpen: false,
265
187
  precartOpen: false,
266
- collectionStyleSkus: {},
188
+ skuStyleIds: {},
267
189
  skuPrices: {},
268
190
  collectionSkus: {},
269
191
  fetchedCollectionIds: [],
@@ -4,6 +4,8 @@ import { resolveFontdueServerConfig } from './serverConfig.js';
4
4
  import { PREVIEW_HEADER, hasPreviewMarkerCookie } from '../preview/constants.js';
5
5
  import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
6
6
  import { CLIENT_VERSION_HEADER, version } from '../version.js';
7
+ import { applyCustomerAuthHeader, customerAuthErrorCode, refreshCustomerSession } from '../auth/session.js';
8
+ import { clearTokens } from '../auth/tokenStore.js';
7
9
 
8
10
  // Re-exported so UI (the admin toolbar) can surface the package version
9
11
  // without re-reading the build-time global in a 'use client' module.
@@ -174,65 +176,104 @@ export function createNetworkFetch(options) {
174
176
  tags: ['graphql', ...((serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.cacheTags) ?? []), `operation:${request.name}`]
175
177
  };
176
178
  }
177
- for (let attempt = 0; attempt <= 2; attempt++) {
178
- try {
179
- const resp = await fetch(url + `?queryName=${request.name}`, init);
180
179
 
181
- // A non-2xx response is a failed request, never data. Returning its
182
- // body used to let a 5xx that happened to parse as JSON (Cloudflare
183
- // answers an `Accept: application/json` client with one) reach Relay
184
- // as a payload and, on the server, get serialized into a prerendered
185
- // page that then crashed every visitor on hydration.
186
- if (!resp.ok) {
187
- if (attempt < 2 && isRetryableStatus(resp.status)) {
180
+ // The request itself, retries and all. Named so the customer-session
181
+ // logic below can re-run it after renewing an expired access token.
182
+ async function send() {
183
+ for (let attempt = 0; attempt <= 2; attempt++) {
184
+ try {
185
+ const resp = await fetch(url + `?queryName=${request.name}`, init);
186
+
187
+ // A non-2xx response is a failed request, never data. Returning its
188
+ // body used to let a 5xx that happened to parse as JSON (Cloudflare
189
+ // answers an `Accept: application/json` client with one) reach Relay
190
+ // as a payload — and, on the server, get serialized into a prerendered
191
+ // page that then crashed every visitor on hydration.
192
+ if (!resp.ok) {
193
+ if (attempt < 2 && isRetryableStatus(resp.status)) {
194
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
195
+ continue;
196
+ }
197
+ const detail = await errorMessageOf(resp);
198
+ throw new FontdueResponseError(`fontdue-js: ${request.name} failed with HTTP ${resp.status}` + (detail ? `: ${detail}` : ''), resp.status, request.name);
199
+ }
200
+ const json = await resp.json();
201
+ if (!isGraphQLResponse(json)) {
202
+ throw new FontdueResponseError(`fontdue-js: ${request.name} returned HTTP ${resp.status} with a body that is not a GraphQL response`, resp.status, request.name);
203
+ }
204
+
205
+ // GraphQL returns exceptions (for example, a missing required variable) in the "errors"
206
+ // property of the response. If any exceptions occurred when processing the request,
207
+ // throw an error to indicate to the developer what went wrong.
208
+ const {
209
+ errors
210
+ } = json;
211
+ if (Array.isArray(errors) && errors[0]) {
212
+ var _error$extensions;
213
+ const error = errors[0];
214
+ console.error('GraphQL Error:', {
215
+ message: error.message,
216
+ code: (_error$extensions = error.extensions) === null || _error$extensions === void 0 ? void 0 : _error$extensions.code,
217
+ path: error.path
218
+ });
219
+ }
220
+ return json;
221
+ } catch (error) {
222
+ if (error instanceof FontdueResponseError) throw error;
223
+ // Retry on network errors (TypeError) before falling through to CORS detection
224
+ if (attempt < 2 && error instanceof TypeError) {
188
225
  await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
189
226
  continue;
190
227
  }
191
- const detail = await errorMessageOf(resp);
192
- throw new FontdueResponseError(`fontdue-js: ${request.name} failed with HTTP ${resp.status}` + (detail ? `: ${detail}` : ''), resp.status, request.name);
193
- }
194
- const json = await resp.json();
195
- if (!isGraphQLResponse(json)) {
196
- throw new FontdueResponseError(`fontdue-js: ${request.name} returned HTTP ${resp.status} with a body that is not a GraphQL response`, resp.status, request.name);
228
+ if (handlePossibleCorsError(error, url)) {
229
+ return {
230
+ data: undefined,
231
+ errors: [{
232
+ message: 'Cross-origin request blocked'
233
+ }]
234
+ };
235
+ }
236
+ throw error;
197
237
  }
238
+ }
198
239
 
199
- // GraphQL returns exceptions (for example, a missing required variable) in the "errors"
200
- // property of the response. If any exceptions occurred when processing the request,
201
- // throw an error to indicate to the developer what went wrong.
202
- const {
203
- errors
204
- } = json;
205
- if (Array.isArray(errors) && errors[0]) {
206
- var _error$extensions;
207
- const error = errors[0];
208
- console.error('GraphQL Error:', {
209
- message: error.message,
210
- code: (_error$extensions = error.extensions) === null || _error$extensions === void 0 ? void 0 : _error$extensions.code,
211
- path: error.path
212
- });
240
+ // Unreachable final iteration either returns or throws
241
+ throw new Error('Unexpected: exhausted retries');
242
+ }
243
+
244
+ // The signed-in customer's access token, in the browser only. Set *after*
245
+ // `previewing` is decided, so a customer session can never be mistaken for
246
+ // an admin preview, and only when nothing else has claimed the header —
247
+ // a forwarded preview token is the page author's credential and wins.
248
+ //
249
+ // `headers` is the same object `init.headers` holds, so renewing the token
250
+ // below and re-running the fetch sends the new one.
251
+ const authScope = IS_SERVER ? undefined : base;
252
+ const customerSession = !IS_SERVER && headers.Authorization == null && headers.authorization == null && applyCustomerAuthHeader(headers, authScope);
253
+ const json = await send();
254
+ if (!customerSession) return json;
255
+ switch (customerAuthErrorCode(json)) {
256
+ // The everyday case: 24 hours have passed since the last visit. Renew
257
+ // once and re-run the request, so the visitor never sees a signed-out
258
+ // page they have to sign back into.
259
+ case 'token:expired':
260
+ {
261
+ const renewed = await refreshCustomerSession(url, authScope, headers);
262
+ if (renewed == null) return json;
263
+ applyCustomerAuthHeader(headers, authScope);
264
+ return await send();
213
265
  }
266
+
267
+ // Not renewable, and not a request the customer can retry into: forged,
268
+ // tampered with, or naming a customer who no longer exists. Forget it so
269
+ // the UI shows a signed-out state rather than retrying every request.
270
+ case 'token:invalid':
271
+ case 'customer:required':
272
+ clearTokens(authScope);
273
+ return json;
274
+ default:
214
275
  return json;
215
- } catch (error) {
216
- if (error instanceof FontdueResponseError) throw error;
217
- // Retry on network errors (TypeError) before falling through to CORS detection
218
- if (attempt < 2 && error instanceof TypeError) {
219
- await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
220
- continue;
221
- }
222
- if (handlePossibleCorsError(error, url)) {
223
- return {
224
- data: undefined,
225
- errors: [{
226
- message: 'Cross-origin request blocked'
227
- }]
228
- };
229
- }
230
- throw error;
231
- }
232
276
  }
233
-
234
- // Unreachable — final iteration either returns or throws
235
- throw new Error('Unexpected: exhausted retries');
236
277
  };
237
278
  }
238
279
  export const networkFetch = createNetworkFetch();
@@ -0,0 +1,40 @@
1
+ import type { FontdueState } from './reducer.js';
2
+ type Catalog = Pick<FontdueState, 'skuStyleIds' | 'skuPrices'>;
3
+ /**
4
+ * The cheapest set of SKUs in which each of `picks` sits inside one SKU
5
+ * granting all of its styles, sorted. Picks whose styles or price aren't known
6
+ * are left out.
7
+ *
8
+ * @example
9
+ * cheapestSkuIds(['sku:bold', 'sku:regular', 'sku:light'], catalog)
10
+ * // => ['sku:light+regular+bold'] rather than a pair plus a single
11
+ */
12
+ export declare const cheapestSkuIds: (picks: readonly string[], catalog: Catalog) => string[];
13
+ /**
14
+ * Re-derives `selectedSkuIds` from the picks. A pick whose styles or price
15
+ * aren't known yet – selected from the host page before its product data
16
+ * arrived – is bought as it is until they are.
17
+ */
18
+ export declare const repriceSelection: (state: FontdueState) => FontdueState;
19
+ /**
20
+ * Adds a pick, or takes out every pick sharing a style with the clicked SKU.
21
+ *
22
+ * If what's left is still cheapest bought with a SKU granting the clicked one's
23
+ * styles anyway – it's part of a bundle that costs no more without it – the
24
+ * click would change nothing, so the picks inside the selected SKUs granting
25
+ * them go too, as deselecting a member of a selected bundle always has.
26
+ */
27
+ export declare const selectSku: (state: FontdueState, skuId: string, selected: boolean) => FontdueState;
28
+ /**
29
+ * Whether a SKU – or a style with no SKU of its own – is in the selection:
30
+ * selected itself, or every style it grants is granted by what is selected.
31
+ */
32
+ export declare const isSelected: (skuIdOrStyleId: string | null) => (state: FontdueState) => boolean;
33
+ /**
34
+ * What picking a SKU would add to the selection's price: the cheapest
35
+ * combination with it, less the current one. Never more than the SKU's own
36
+ * price, and less when it completes a cheaper bundle – the Store Modal's
37
+ * “+$X”. Null when the SKU's styles or price aren't known.
38
+ */
39
+ export declare const additionalCost: (state: FontdueState, skuId: string) => number | null;
40
+ export {};