fontdue-js 3.12.0 → 3.12.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 3.12.1
2
+
3
+ - Fixed a `TypeTester`’s style menu showing only the current style on the first click, most visibly in Safari. The style list now loads as the type tester nears the viewport.
4
+ - Fixed the `StoreModal` checkout showing Stripe’s generic error when Stripe refuses to start a checkout, such as for a total under its minimum charge. It now shows Stripe’s reason.
5
+
1
6
  ## 3.12.0
2
7
 
3
8
  - `CustomerLoginForm` runs an invisible reCAPTCHA check when *Enable reCAPTCHA* is on under *Settings → Integration*, giving the sign-in form the same bot protection as `NewsletterSignup` and `TestFontsForm`.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @generated SignedSource<<115e004f4831c3eb09ea98ab9cd0934a>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+ import { ConcreteRequest } from 'relay-runtime';
7
+ export type CustomerSignInElementQuery$variables = Record<PropertyKey, never>;
8
+ export type CustomerSignInElementQuery$data = {
9
+ readonly viewer: {
10
+ readonly currentCustomer: {
11
+ readonly email: string | null;
12
+ readonly id: string;
13
+ readonly name: string | null;
14
+ } | null;
15
+ };
16
+ };
17
+ export type CustomerSignInElementQuery = {
18
+ response: CustomerSignInElementQuery$data;
19
+ variables: CustomerSignInElementQuery$variables;
20
+ };
21
+ declare const node: ConcreteRequest;
22
+ export default node;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @generated SignedSource<<115e004f4831c3eb09ea98ab9cd0934a>>
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
+ "concreteType": "Customer",
23
+ "kind": "LinkedField",
24
+ "name": "currentCustomer",
25
+ "plural": false,
26
+ "selections": [v0 /*: any*/, {
27
+ "alias": null,
28
+ "args": null,
29
+ "kind": "ScalarField",
30
+ "name": "name",
31
+ "storageKey": null
32
+ }, {
33
+ "alias": null,
34
+ "args": null,
35
+ "kind": "ScalarField",
36
+ "name": "email",
37
+ "storageKey": null
38
+ }],
39
+ "storageKey": null
40
+ };
41
+ return {
42
+ "fragment": {
43
+ "argumentDefinitions": [],
44
+ "kind": "Fragment",
45
+ "metadata": null,
46
+ "name": "CustomerSignInElementQuery",
47
+ "selections": [{
48
+ "alias": null,
49
+ "args": null,
50
+ "concreteType": "Viewer",
51
+ "kind": "LinkedField",
52
+ "name": "viewer",
53
+ "plural": false,
54
+ "selections": [v1 /*: any*/],
55
+ "storageKey": null
56
+ }],
57
+ "type": "RootQueryType",
58
+ "abstractKey": null
59
+ },
60
+ "kind": "Request",
61
+ "operation": {
62
+ "argumentDefinitions": [],
63
+ "kind": "Operation",
64
+ "name": "CustomerSignInElementQuery",
65
+ "selections": [{
66
+ "alias": null,
67
+ "args": null,
68
+ "concreteType": "Viewer",
69
+ "kind": "LinkedField",
70
+ "name": "viewer",
71
+ "plural": false,
72
+ "selections": [v1 /*: any*/, v0 /*: any*/],
73
+ "storageKey": null
74
+ }]
75
+ },
76
+ "params": {
77
+ "cacheID": "3f3148c6d2bf1f2b90edfb3f217f6e50",
78
+ "id": null,
79
+ "metadata": {},
80
+ "name": "CustomerSignInElementQuery",
81
+ "operationKind": "query",
82
+ "text": "query CustomerSignInElementQuery {\n viewer {\n currentCustomer {\n id\n name\n email\n }\n id\n }\n}\n"
83
+ }
84
+ };
85
+ }();
86
+ node.hash = "e7d4a8fb8d19581d6bc36f29090230af";
87
+ export default node;
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // `<fontdue-customer-sign-in>`: a script-tag page has no React of its own, so
4
+ // the element shows who is signed in and tells the page about sign-in and
5
+ // sign-out with window events.
6
+
7
+ import React, { act } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ const {
11
+ relay
12
+ } = vi.hoisted(() => ({
13
+ relay: {
14
+ currentCustomer: null,
15
+ lookups: 0
16
+ }
17
+ }));
18
+ vi.mock('react-relay', () => ({
19
+ graphql: () => null,
20
+ useLazyLoadQuery: () => {
21
+ relay.lookups += 1;
22
+ return {
23
+ viewer: {
24
+ currentCustomer: relay.currentCustomer
25
+ }
26
+ };
27
+ }
28
+ }));
29
+
30
+ // The form itself is CustomerSignIn's business; stand in a button that signs
31
+ // in the way it does – tokens stored first, then `onSignedIn`.
32
+ vi.mock('../components/CustomerSignIn/index.js', async () => {
33
+ const {
34
+ setTokens
35
+ } = await import('../auth/tokenStore.js');
36
+ return {
37
+ default: _ref => {
38
+ let {
39
+ onSignedIn
40
+ } = _ref;
41
+ return /*#__PURE__*/React.createElement("button", {
42
+ "data-testid": "form",
43
+ onClick: () => {
44
+ setTokens({
45
+ accessToken: 'fresh',
46
+ refreshToken: 'r'
47
+ });
48
+ onSignedIn({
49
+ id: 'C2',
50
+ name: 'Nina',
51
+ email: 'nina@example.com'
52
+ });
53
+ }
54
+ });
55
+ }
56
+ };
57
+ });
58
+ import CustomerSignInElement from '../components/CustomerSignIn/CustomerSignInElement.js';
59
+ import { getTokens, resetTokenStore, setTokens } from '../auth/tokenStore.js';
60
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
61
+ let container;
62
+ let root;
63
+ let events;
64
+ const record = event => events.push({
65
+ type: event.type,
66
+ detail: event.detail
67
+ });
68
+ beforeEach(() => {
69
+ window.localStorage.clear();
70
+ resetTokenStore();
71
+ relay.currentCustomer = null;
72
+ relay.lookups = 0;
73
+ events = [];
74
+ window.addEventListener('fontdue:customer-signed-in', record);
75
+ window.addEventListener('fontdue:customer-signed-out', record);
76
+ container = document.createElement('div');
77
+ document.body.append(container);
78
+ root = createRoot(container);
79
+ });
80
+ afterEach(() => {
81
+ act(() => root.unmount());
82
+ container.remove();
83
+ window.removeEventListener('fontdue:customer-signed-in', record);
84
+ window.removeEventListener('fontdue:customer-signed-out', record);
85
+ });
86
+ const render = async () => {
87
+ await act(async () => root.render( /*#__PURE__*/React.createElement(CustomerSignInElement, null)));
88
+ };
89
+ const click = async selector => {
90
+ await act(async () => container.querySelector(selector).click());
91
+ };
92
+ describe('<fontdue-customer-sign-in>', () => {
93
+ it('shows the form to a visitor with no session, without a lookup', async () => {
94
+ await render();
95
+ expect(container.querySelector('[data-testid="form"]')).not.toBeNull();
96
+ expect(relay.lookups).toBe(0);
97
+ });
98
+ it('shows a returning customer who is signed in, not the form', async () => {
99
+ setTokens({
100
+ accessToken: 'stored',
101
+ refreshToken: 'r'
102
+ });
103
+ relay.currentCustomer = {
104
+ id: 'C1',
105
+ name: 'Ada',
106
+ email: 'ada@example.com'
107
+ };
108
+ await render();
109
+ expect(container.querySelector('[data-testid="form"]')).toBeNull();
110
+ expect(container.textContent).toContain('Signed in as ada@example.com.');
111
+ });
112
+ it('offers the form when the session names nobody', async () => {
113
+ setTokens({
114
+ accessToken: 'stored',
115
+ refreshToken: 'r'
116
+ });
117
+ await render();
118
+ expect(container.querySelector('[data-testid="form"]')).not.toBeNull();
119
+ });
120
+ it('shows the customer it just signed in at once and tells the page', async () => {
121
+ await render();
122
+ await click('[data-testid="form"]');
123
+ expect(container.textContent).toContain('Signed in as nina@example.com.');
124
+ expect(relay.lookups).toBe(0);
125
+ expect(events).toEqual([{
126
+ type: 'fontdue:customer-signed-in',
127
+ detail: {
128
+ customer: {
129
+ id: 'C2',
130
+ name: 'Nina',
131
+ email: 'nina@example.com'
132
+ }
133
+ }
134
+ }]);
135
+ });
136
+ it('signs out: forgets the session, shows the form again and tells the page', async () => {
137
+ setTokens({
138
+ accessToken: 'stored',
139
+ refreshToken: 'r'
140
+ });
141
+ relay.currentCustomer = {
142
+ id: 'C1',
143
+ name: 'Ada',
144
+ email: 'ada@example.com'
145
+ };
146
+ await render();
147
+ await click('[data-step="signed-in"] button');
148
+ expect(getTokens()).toBeNull();
149
+ expect(container.querySelector('[data-testid="form"]')).not.toBeNull();
150
+ expect(events).toEqual([{
151
+ type: 'fontdue:customer-signed-out',
152
+ detail: null
153
+ }]);
154
+ });
155
+ });
@@ -0,0 +1,85 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // Asking for a sign-in code: the only unsuccessful *answer* is the rate limit,
4
+ // but a request can also fail outright, and that must not be reported as one.
5
+
6
+ import React, { act } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ const {
10
+ relay
11
+ } = vi.hoisted(() => ({
12
+ relay: {
13
+ respond: null
14
+ }
15
+ }));
16
+ vi.mock('react-relay', () => ({
17
+ graphql: () => null,
18
+ useMutation: () => [config => {
19
+ var _relay$respond;
20
+ return (_relay$respond = relay.respond) === null || _relay$respond === void 0 ? void 0 : _relay$respond.call(relay, config.onCompleted);
21
+ }, false]
22
+ }));
23
+ vi.mock('../components/FontdueContextProvider/index.js', () => ({
24
+ EnsureFontdueContext: _ref => {
25
+ let {
26
+ children
27
+ } = _ref;
28
+ return /*#__PURE__*/React.createElement(React.Fragment, null, children);
29
+ }
30
+ }));
31
+ import CustomerSignIn from '../components/CustomerSignIn/index.js';
32
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
33
+ let container;
34
+ let root;
35
+ beforeEach(() => {
36
+ container = document.createElement('div');
37
+ document.body.append(container);
38
+ root = createRoot(container);
39
+ });
40
+ afterEach(() => {
41
+ act(() => root.unmount());
42
+ container.remove();
43
+ });
44
+ const requestCode = async () => {
45
+ await act(async () => root.render( /*#__PURE__*/React.createElement(CustomerSignIn, {
46
+ email: "reader@example.com"
47
+ })));
48
+ await act(async () => {
49
+ container.querySelector('form').dispatchEvent(new Event('submit', {
50
+ bubbles: true,
51
+ cancelable: true
52
+ }));
53
+ });
54
+ };
55
+ describe('CustomerSignIn asking for a code', () => {
56
+ it('moves to the code step when the code is on its way', async () => {
57
+ relay.respond = done => done({
58
+ requestLoginCode: {
59
+ success: true
60
+ }
61
+ });
62
+ await requestCode();
63
+ expect(container.querySelector('[data-step="code"]')).not.toBeNull();
64
+ });
65
+ it('reports the rate limit', async () => {
66
+ relay.respond = done => done({
67
+ requestLoginCode: {
68
+ success: false
69
+ }
70
+ });
71
+ await requestCode();
72
+ expect(container.textContent).toContain('Too many codes requested');
73
+ });
74
+ it('reports a failed request as itself, not as the rate limit', async () => {
75
+ relay.respond = done => done({
76
+ requestLoginCode: null
77
+ }, [{
78
+ message: 'Something went wrong'
79
+ }]);
80
+ await requestCode();
81
+ expect(container.querySelector('[data-step="email"]')).not.toBeNull();
82
+ expect(container.textContent).toContain('Something went wrong');
83
+ expect(container.textContent).not.toContain('Too many codes requested');
84
+ });
85
+ });
@@ -0,0 +1,110 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // When Stripe refuses to create the Checkout Session (a total under its $0.50
4
+ // minimum, say), the embedded checkout only says “Something went wrong”. The
5
+ // store modal shows Stripe's reason in its place instead.
6
+
7
+ import React, { act, useEffect } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ const {
11
+ relay
12
+ } = vi.hoisted(() => ({
13
+ relay: {
14
+ response: null,
15
+ errors: undefined,
16
+ networkError: null
17
+ }
18
+ }));
19
+ vi.mock('react-relay', () => ({
20
+ graphql: () => null,
21
+ useRelayEnvironment: () => ({}),
22
+ useFragment: () => ({
23
+ stripeAccountId: 'acct_test',
24
+ stripePublishableKey: 'pk_test'
25
+ }),
26
+ commitMutation: (_env, config) => {
27
+ if (relay.networkError) config.onError(relay.networkError);else config.onCompleted(relay.response, relay.errors);
28
+ }
29
+ }));
30
+ vi.mock('@stripe/stripe-js', () => ({
31
+ loadStripe: () => Promise.resolve({})
32
+ }));
33
+
34
+ // react-stripe-js asks for the client secret as soon as Stripe.js has loaded.
35
+ vi.mock('@stripe/react-stripe-js', () => ({
36
+ Elements: _ref => {
37
+ let {
38
+ children
39
+ } = _ref;
40
+ return /*#__PURE__*/React.createElement(React.Fragment, null, children);
41
+ },
42
+ EmbeddedCheckoutProvider: _ref2 => {
43
+ let {
44
+ options,
45
+ children
46
+ } = _ref2;
47
+ useEffect(() => {
48
+ options.fetchClientSecret().catch(() => {});
49
+ }, []);
50
+ return /*#__PURE__*/React.createElement(React.Fragment, null, children);
51
+ }
52
+ }));
53
+ import StripeProvider from '../components/StripeProvider/index.js';
54
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
55
+ let container;
56
+ let root;
57
+ beforeEach(() => {
58
+ relay.response = {
59
+ createOrderCheckoutSession: null
60
+ };
61
+ relay.errors = undefined;
62
+ relay.networkError = null;
63
+ container = document.createElement('div');
64
+ document.body.append(container);
65
+ root = createRoot(container);
66
+ });
67
+ afterEach(() => {
68
+ act(() => root.unmount());
69
+ container.remove();
70
+ });
71
+ const render = async () => {
72
+ await act(async () => root.render( /*#__PURE__*/React.createElement(StripeProvider, {
73
+ viewer: {},
74
+ providerType: "checkout"
75
+ }, /*#__PURE__*/React.createElement("div", {
76
+ "data-testid": "embedded-checkout"
77
+ }))));
78
+ };
79
+ describe('StripeProvider embedded checkout errors', () => {
80
+ it('shows Stripe’s reason when the Checkout Session is refused', async () => {
81
+ var _container$querySelec;
82
+ const message = "The Checkout Session's total amount due must add up to at least $0.50 usd";
83
+ relay.errors = [{
84
+ message
85
+ }];
86
+ await render();
87
+ expect((_container$querySelec = container.querySelector('[role="alert"]')) === null || _container$querySelec === void 0 ? void 0 : _container$querySelec.textContent).toBe(message);
88
+ expect(container.querySelector('[data-testid="embedded-checkout"]')).toBeNull();
89
+ });
90
+ it('falls back to a generic message when the request itself fails', async () => {
91
+ var _container$querySelec2;
92
+ relay.networkError = new Error('fontdue-js: request failed with HTTP 502');
93
+ await render();
94
+ expect((_container$querySelec2 = container.querySelector('[role="alert"]')) === null || _container$querySelec2 === void 0 ? void 0 : _container$querySelec2.textContent).toBe('We couldn’t start the checkout – please try again');
95
+ });
96
+ it('mounts the embedded checkout when the session is created', async () => {
97
+ relay.response = {
98
+ createOrderCheckoutSession: {
99
+ order: {
100
+ stripeCheckoutSession: {
101
+ clientSecret: 'cs_test_secret'
102
+ }
103
+ }
104
+ }
105
+ };
106
+ await render();
107
+ expect(container.querySelector('[role="alert"]')).toBeNull();
108
+ expect(container.querySelector('[data-testid="embedded-checkout"]')).not.toBeNull();
109
+ });
110
+ });
@@ -0,0 +1,108 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useRef } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import usePrefetchWhenNear from '../components/TypeTester/usePrefetchWhenNear.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ // A controllable IntersectionObserver: tests decide when the element is near.
9
+ let observers = [];
10
+ class FakeIntersectionObserver {
11
+ constructor(cb) {
12
+ this.entry = {
13
+ cb,
14
+ disconnected: false
15
+ };
16
+ observers.push(this.entry);
17
+ }
18
+ observe() {}
19
+ disconnect() {
20
+ this.entry.disconnected = true;
21
+ }
22
+ }
23
+ const intersect = isIntersecting => act(() => {
24
+ observers.filter(o => !o.disconnected).forEach(o => o.cb([{
25
+ isIntersecting
26
+ }], {}));
27
+ });
28
+ function Select(_ref) {
29
+ let {
30
+ prefetch
31
+ } = _ref;
32
+ const ref = useRef(null);
33
+ usePrefetchWhenNear(ref, prefetch);
34
+ return /*#__PURE__*/React.createElement("div", {
35
+ ref: ref
36
+ });
37
+ }
38
+
39
+ // Native <select> menus snapshot their options on open (Safari never updates
40
+ // an open menu), so the type tester's style list has to be fetched before the
41
+ // first click, without competing with the initial render.
42
+ describe('type tester style list prefetch', () => {
43
+ let root;
44
+ let container;
45
+ let prefetch;
46
+ beforeEach(() => {
47
+ vi.useFakeTimers();
48
+ observers = [];
49
+ vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
50
+ // Safari: no requestIdleCallback, so the timeout fallback is exercised.
51
+ vi.stubGlobal('requestIdleCallback', undefined);
52
+ container = document.createElement('div');
53
+ document.body.append(container);
54
+ root = createRoot(container);
55
+ prefetch = vi.fn();
56
+ });
57
+ afterEach(() => {
58
+ act(() => root.unmount());
59
+ container.remove();
60
+ vi.unstubAllGlobals();
61
+ vi.useRealTimers();
62
+ });
63
+ const render = () => act(() => root.render( /*#__PURE__*/React.createElement(Select, {
64
+ prefetch: prefetch
65
+ })));
66
+ it('waits until the tester is near the viewport', async () => {
67
+ await render();
68
+ await act(() => vi.advanceTimersByTime(5000));
69
+ expect(prefetch).not.toHaveBeenCalled();
70
+ await intersect(false);
71
+ await act(() => vi.advanceTimersByTime(5000));
72
+ expect(prefetch).not.toHaveBeenCalled();
73
+ });
74
+ it('prefetches at idle once near, not during render', async () => {
75
+ await render();
76
+ await intersect(true);
77
+ expect(prefetch).not.toHaveBeenCalled();
78
+ await act(() => vi.advanceTimersByTime(1000));
79
+ expect(prefetch).toHaveBeenCalledTimes(1);
80
+ });
81
+ it('prefetches only once', async () => {
82
+ await render();
83
+ await intersect(true);
84
+ await act(() => vi.advanceTimersByTime(1000));
85
+ await intersect(true);
86
+ await act(() => vi.advanceTimersByTime(1000));
87
+ expect(prefetch).toHaveBeenCalledTimes(1);
88
+ });
89
+ it('cancels a pending prefetch on unmount', async () => {
90
+ await render();
91
+ await intersect(true);
92
+ act(() => root.render( /*#__PURE__*/React.createElement(React.Fragment, null)));
93
+ await act(() => vi.advanceTimersByTime(1000));
94
+ expect(prefetch).not.toHaveBeenCalled();
95
+ });
96
+ it('uses requestIdleCallback where the browser has it', async () => {
97
+ const idle = vi.fn(cb => setTimeout(cb, 10));
98
+ vi.stubGlobal('requestIdleCallback', idle);
99
+ vi.stubGlobal('cancelIdleCallback', vi.fn());
100
+ await render();
101
+ await intersect(true);
102
+ expect(idle).toHaveBeenCalledWith(expect.any(Function), {
103
+ timeout: 2000
104
+ });
105
+ await act(() => vi.advanceTimersByTime(10));
106
+ expect(prefetch).toHaveBeenCalledTimes(1);
107
+ });
108
+ });
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { CustomerSignIn_props } from './index.js';
3
+ /** Dispatched on `window` once the customer is signed in; `detail.customer`. */
4
+ export declare const SIGNED_IN_EVENT = "fontdue:customer-signed-in";
5
+ /** Dispatched on `window` when the customer clicks the element's sign-out button. */
6
+ export declare const SIGNED_OUT_EVENT = "fontdue:customer-signed-out";
7
+ interface CustomerSignInElement_props extends Omit<CustomerSignIn_props, 'onSignedIn'> {
8
+ /** Label on the button that signs the customer out. */
9
+ signOutLabel?: string;
10
+ }
11
+ /**
12
+ * `<fontdue-customer-sign-in>`: the script-tag wrapper around `CustomerSignIn`.
13
+ *
14
+ * A page has no React of its own to read `useCurrentCustomer()` with, so the
15
+ * element shows the signed-in state itself – who is signed in and a way out –
16
+ * and tells the page about both changes with window events, as `setConsent`
17
+ * does with `fontdue:consent`.
18
+ *
19
+ * Not in the npm package's `exports`: sign-in is script-tag only for now.
20
+ */
21
+ export default function CustomerSignInElement({ signOutLabel, ...props }: CustomerSignInElement_props): React.JSX.Element;
22
+ export {};
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
4
+ import _CustomerSignInElementQuery from "../../__generated__/CustomerSignInElementQuery.graphql.js";
5
+ import React, { Suspense, useCallback, useState, useSyncExternalStore } from 'react';
6
+ import { ErrorBoundary } from 'react-error-boundary';
7
+ import { graphql, useLazyLoadQuery } from 'react-relay';
8
+ import CustomerSignIn from './index.js';
9
+ import { clearTokens, getTokens, subscribeToTokens } from '../../auth/tokenStore.js';
10
+ import { useFontdueUrl } from '../UrlContext.js';
11
+ const query = (_CustomerSignInElementQuery.hash && _CustomerSignInElementQuery.hash !== "e7d4a8fb8d19581d6bc36f29090230af" && console.error("The definition of 'CustomerSignInElementQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerSignInElementQuery);
12
+
13
+ /** Dispatched on `window` once the customer is signed in; `detail.customer`. */
14
+ export const SIGNED_IN_EVENT = 'fontdue:customer-signed-in';
15
+ /** Dispatched on `window` when the customer clicks the element's sign-out button. */
16
+ export const SIGNED_OUT_EVENT = 'fontdue:customer-signed-out';
17
+ /**
18
+ * `<fontdue-customer-sign-in>`: the script-tag wrapper around `CustomerSignIn`.
19
+ *
20
+ * A page has no React of its own to read `useCurrentCustomer()` with, so the
21
+ * element shows the signed-in state itself – who is signed in and a way out –
22
+ * and tells the page about both changes with window events, as `setConsent`
23
+ * does with `fontdue:consent`.
24
+ *
25
+ * Not in the npm package's `exports`: sign-in is script-tag only for now.
26
+ */
27
+ export default function CustomerSignInElement(_ref) {
28
+ let {
29
+ signOutLabel = 'Sign out',
30
+ ...props
31
+ } = _ref;
32
+ const url = useFontdueUrl();
33
+ // The script tag never renders on a server, so reading the store during
34
+ // render can't cause a hydration mismatch – and it means a returning
35
+ // customer never sees the form flash before their session is read.
36
+ const accessToken = useSyncExternalStore(subscribeToTokens, () => {
37
+ var _getTokens;
38
+ return ((_getTokens = getTokens(url)) === null || _getTokens === void 0 ? void 0 : _getTokens.accessToken) ?? null;
39
+ }, () => null);
40
+ // The customer `verifyLoginCode` just returned, tied to the token it came
41
+ // with, so the signed-in state shows at once instead of after a lookup. Any
42
+ // other session – a page load, a sign-in in another element, a renewed
43
+ // token – is looked up.
44
+ const [justSignedIn, setJustSignedIn] = useState(null);
45
+ const handleSignedIn = useCallback(customer => {
46
+ if (customer) {
47
+ var _getTokens2;
48
+ setJustSignedIn({
49
+ accessToken: (_getTokens2 = getTokens(url)) === null || _getTokens2 === void 0 ? void 0 : _getTokens2.accessToken,
50
+ customer
51
+ });
52
+ }
53
+ window.dispatchEvent(new CustomEvent(SIGNED_IN_EVENT, {
54
+ detail: {
55
+ customer
56
+ }
57
+ }));
58
+ }, [url]);
59
+ const signOut = useCallback(() => {
60
+ clearTokens(url);
61
+ setJustSignedIn(null);
62
+ window.dispatchEvent(new CustomEvent(SIGNED_OUT_EVENT));
63
+ }, [url]);
64
+ const form = /*#__PURE__*/React.createElement(CustomerSignIn, _extends({}, props, {
65
+ onSignedIn: handleSignedIn
66
+ }));
67
+ if (accessToken == null) return form;
68
+ if (justSignedIn && justSignedIn.accessToken === accessToken) {
69
+ return /*#__PURE__*/React.createElement(SignedIn, {
70
+ customer: justSignedIn.customer,
71
+ signOut: signOut,
72
+ signOutLabel: signOutLabel
73
+ });
74
+ }
75
+
76
+ // A lookup that fails leaves the visitor able to sign in again rather than
77
+ // facing an empty element.
78
+ return /*#__PURE__*/React.createElement(ErrorBoundary, {
79
+ fallback: form,
80
+ resetKeys: [accessToken]
81
+ }, /*#__PURE__*/React.createElement(Suspense, {
82
+ fallback: null
83
+ }, /*#__PURE__*/React.createElement(SignedInLookup, {
84
+ key: accessToken,
85
+ form: form,
86
+ signOut: signOut,
87
+ signOutLabel: signOutLabel
88
+ })));
89
+ }
90
+ function SignedInLookup(_ref2) {
91
+ var _data$viewer;
92
+ let {
93
+ form,
94
+ signOut,
95
+ signOutLabel
96
+ } = _ref2;
97
+ // network-only: who is signed in *now*, never a store hit left over from the
98
+ // previous customer.
99
+ const data = useLazyLoadQuery(query, {}, {
100
+ fetchPolicy: 'network-only'
101
+ });
102
+ const customer = (_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : _data$viewer.currentCustomer;
103
+
104
+ // A token the server no longer honours is cleared by the network layer,
105
+ // which re-renders the element signed out. A null here without that is a
106
+ // session that names nobody; offer the form.
107
+ if (!customer) return form;
108
+ return /*#__PURE__*/React.createElement(SignedIn, {
109
+ customer: customer,
110
+ signOut: signOut,
111
+ signOutLabel: signOutLabel
112
+ });
113
+ }
114
+ function SignedIn(_ref3) {
115
+ let {
116
+ customer,
117
+ signOut,
118
+ signOutLabel
119
+ } = _ref3;
120
+ return /*#__PURE__*/React.createElement("div", {
121
+ className: "customer-sign-in",
122
+ "data-step": "signed-in"
123
+ }, /*#__PURE__*/React.createElement("p", {
124
+ className: "customer-sign-in__intro"
125
+ }, "Signed in as ", customer.email ?? customer.name, "."), /*#__PURE__*/React.createElement("div", {
126
+ className: "customer-sign-in__actions"
127
+ }, /*#__PURE__*/React.createElement("button", {
128
+ className: "customer-sign-in__back",
129
+ type: "button",
130
+ onClick: signOut
131
+ }, signOutLabel)));
132
+ }
@@ -54,9 +54,11 @@ function CustomerSignInFields(_ref) {
54
54
  email
55
55
  }
56
56
  },
57
- onCompleted: res => {
57
+ onCompleted: (res, errors) => {
58
58
  var _res$requestLoginCode;
59
- if ((_res$requestLoginCode = res.requestLoginCode) !== null && _res$requestLoginCode !== void 0 && _res$requestLoginCode.success) {
59
+ if (errors && errors.length > 0) {
60
+ setError(errors[0].message);
61
+ } else if ((_res$requestLoginCode = res.requestLoginCode) !== null && _res$requestLoginCode !== void 0 && _res$requestLoginCode.success) {
60
62
  setStep('code');
61
63
  } else {
62
64
  // The only unsuccessful answer is the rate limit, and it is about the
@@ -25,6 +25,8 @@ const customElementMap = {
25
25
  'fontdue-cookie-notification': /*#__PURE__*/lazy(() => retryImport(() => import('../CookieNotification/index.js'))),
26
26
  'fontdue-font-families': /*#__PURE__*/lazy(() => retryImport(() => import('../FontFamilies/index.js'))),
27
27
  'fontdue-customer-login-form': /*#__PURE__*/lazy(() => retryImport(() => import('../CustomerLoginForm/index.js'))),
28
+ // Script tag only: CustomerSignIn isn't in the npm package's `exports` yet.
29
+ 'fontdue-customer-sign-in': /*#__PURE__*/lazy(() => retryImport(() => import('../CustomerSignIn/CustomerSignInElement.js'))),
28
30
  'fontdue-newsletter-signup': /*#__PURE__*/lazy(() => retryImport(() => import('../NewsletterSignup/NewsletterSignupElement.js'))),
29
31
  'fontdue-node-password-form': /*#__PURE__*/lazy(() => retryImport(() => import('../NodePasswordForm/NodePasswordFormElement.js'))),
30
32
  // @ts-ignore
@@ -1,11 +1,13 @@
1
1
  import _StripeProviderCreateCheckoutSessionMutation from "../../__generated__/StripeProviderCreateCheckoutSessionMutation.graphql.js";
2
2
  import _StripeProvider_viewer from "../../__generated__/StripeProvider_viewer.graphql.js";
3
- import React, { useCallback, useContext, useRef } from 'react';
3
+ import React, { useCallback, useContext, useRef, useState } from 'react';
4
4
  import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
5
5
  import { Elements, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
6
6
  import { loadStripe } from '@stripe/stripe-js';
7
7
  import { parseCustomProperties } from '../../utils.js';
8
8
  import ConfigContext from '../ConfigContext.js';
9
+ import StoreModalPageContainer from '../elements/StoreModalPageContainer/index.js';
10
+ const CHECKOUT_ERROR = 'We couldn’t start the checkout – please try again';
9
11
  const StripeProvider = _ref => {
10
12
  var _data$currentOrder, _data$settings, _data$settings$uiFont, _data$settings$uiFont2, _data$settings$uiFont3, _data$settings2, _data$currentOrder2, _data$currentOrder2$s, _data$themeConfig;
11
13
  let {
@@ -26,6 +28,7 @@ const StripeProvider = _ref => {
26
28
  const {
27
29
  stripe: config
28
30
  } = useContext(ConfigContext);
31
+ const [checkoutError, setCheckoutError] = useState(null);
29
32
  let customFontSource;
30
33
  const woffSrc = (_data$settings = data.settings) === null || _data$settings === void 0 ? void 0 : (_data$settings$uiFont = _data$settings.uiFontStyle) === null || _data$settings$uiFont === void 0 ? void 0 : (_data$settings$uiFont2 = _data$settings$uiFont.webfontSources) === null || _data$settings$uiFont2 === void 0 ? void 0 : (_data$settings$uiFont3 = _data$settings$uiFont2.find(source => (source === null || source === void 0 ? void 0 : source.format) === 'woff2')) === null || _data$settings$uiFont3 === void 0 ? void 0 : _data$settings$uiFont3.url;
31
34
  let fontFamily = (_data$settings2 = data.settings) !== null && _data$settings2 !== void 0 && _data$settings2.uiFontStyle ? `${data.settings.uiFontStyle.cssFamily} ${data.settings.uiFontStyle.name}` : undefined;
@@ -50,16 +53,22 @@ const StripeProvider = _ref => {
50
53
  commitMutation(environment, {
51
54
  mutation: (_StripeProviderCreateCheckoutSessionMutation.hash && _StripeProviderCreateCheckoutSessionMutation.hash !== "2ca9a7fcfed1292117e3cd6d0d95cca6" && console.error("The definition of 'StripeProviderCreateCheckoutSessionMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _StripeProviderCreateCheckoutSessionMutation),
52
55
  variables: {},
53
- onCompleted: response => {
56
+ onCompleted: (response, errors) => {
54
57
  var _response$createOrder, _response$createOrder2, _response$createOrder3;
55
58
  const clientSecret = (_response$createOrder = response.createOrderCheckoutSession) === null || _response$createOrder === void 0 ? void 0 : (_response$createOrder2 = _response$createOrder.order) === null || _response$createOrder2 === void 0 ? void 0 : (_response$createOrder3 = _response$createOrder2.stripeCheckoutSession) === null || _response$createOrder3 === void 0 ? void 0 : _response$createOrder3.clientSecret;
56
59
  if (clientSecret) {
57
60
  resolve(clientSecret);
58
61
  } else {
62
+ var _errors$;
63
+ // Stripe refused the session (e.g. a total under its minimum
64
+ // charge): show its reason, which the embedded checkout would
65
+ // otherwise swallow into a generic error.
66
+ setCheckoutError((errors === null || errors === void 0 ? void 0 : (_errors$ = errors[0]) === null || _errors$ === void 0 ? void 0 : _errors$.message) ?? CHECKOUT_ERROR);
59
67
  reject("Didn't find client secret");
60
68
  }
61
69
  },
62
70
  onError: error => {
71
+ setCheckoutError(CHECKOUT_ERROR);
63
72
  reject(`Error creating checkout session: ${error.message}`);
64
73
  }
65
74
  });
@@ -147,6 +156,14 @@ const StripeProvider = _ref => {
147
156
  syncAddressCheckbox: 'none'
148
157
  };
149
158
  if (providerType === 'checkout') {
159
+ if (checkoutError) {
160
+ return /*#__PURE__*/React.createElement(StoreModalPageContainer, null, {
161
+ body: /*#__PURE__*/React.createElement("div", {
162
+ className: "store-modal__cart__error",
163
+ role: "alert"
164
+ }, checkoutError)
165
+ });
166
+ }
150
167
  return /*#__PURE__*/React.createElement(EmbeddedCheckoutProvider, {
151
168
  stripe: stripePromiseRef.current,
152
169
  options: {
@@ -1,6 +1,7 @@
1
- import React from 'react';
1
+ import React, { useRef } from 'react';
2
2
  import { compareVariableSettings, variableInstanceSettings } from '../../utils.js';
3
3
  import Select from '../Select/index.js';
4
+ import usePrefetchWhenNear from './usePrefetchWhenNear.js';
4
5
  const VARIABLE_AXES = 'VARIABLE_AXES';
5
6
  const LOADING = '__loading__';
6
7
  const TypeTesterStyleSelect = _ref => {
@@ -19,6 +20,8 @@ const TypeTesterStyleSelect = _ref => {
19
20
  onSelectFamilyId,
20
21
  includeVariableAxesOption
21
22
  } = _ref;
23
+ const containerRef = useRef(null);
24
+ usePrefetchWhenNear(containerRef, onOpen);
22
25
  const handleSelectFontStyle = selection => {
23
26
  if (selection === VARIABLE_AXES) {
24
27
  onSelectVariableAxes();
@@ -90,7 +93,9 @@ const TypeTesterStyleSelect = _ref => {
90
93
  cb(e.target.value);
91
94
  };
92
95
  return /*#__PURE__*/React.createElement("div", {
96
+ ref: containerRef,
93
97
  className: "type-tester-select",
98
+ onPointerEnter: () => onOpen === null || onOpen === void 0 ? void 0 : onOpen(),
94
99
  "data-loading": loading || undefined,
95
100
  "aria-busy": loading || undefined,
96
101
  style: {
@@ -0,0 +1,14 @@
1
+ export declare const PREFETCH_ROOT_MARGIN = "300px";
2
+ export declare const PREFETCH_IDLE_TIMEOUT = 2000;
3
+ /**
4
+ * Calls `prefetch` once, at idle time, after the element first comes near the
5
+ * viewport. Native `<select>` menus snapshot their options when they open (in
6
+ * Safari the open menu never updates), so data a dropdown needs has to be
7
+ * there before the first click – fetching on mousedown is too late. Waiting
8
+ * for idle keeps the request off the initial render, and waiting for the
9
+ * viewport keeps a long page of testers from fetching for every one at once.
10
+ *
11
+ * const ref = useRef<HTMLDivElement>(null);
12
+ * usePrefetchWhenNear(ref, loadOptions);
13
+ */
14
+ export default function usePrefetchWhenNear(ref: React.RefObject<Element | null>, prefetch: (() => void) | undefined): void;
@@ -0,0 +1,69 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ // How far outside the viewport a tester counts as "near".
4
+ export const PREFETCH_ROOT_MARGIN = '300px';
5
+ // Upper bound on waiting for the browser to go idle.
6
+ export const PREFETCH_IDLE_TIMEOUT = 2000;
7
+ function onIdle(cb) {
8
+ if (typeof window.requestIdleCallback === 'function') {
9
+ const id = window.requestIdleCallback(cb, {
10
+ timeout: PREFETCH_IDLE_TIMEOUT
11
+ });
12
+ return {
13
+ cancel: () => window.cancelIdleCallback(id)
14
+ };
15
+ }
16
+ // Safari has no requestIdleCallback.
17
+ const id = window.setTimeout(cb, 200);
18
+ return {
19
+ cancel: () => window.clearTimeout(id)
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Calls `prefetch` once, at idle time, after the element first comes near the
25
+ * viewport. Native `<select>` menus snapshot their options when they open (in
26
+ * Safari the open menu never updates), so data a dropdown needs has to be
27
+ * there before the first click – fetching on mousedown is too late. Waiting
28
+ * for idle keeps the request off the initial render, and waiting for the
29
+ * viewport keeps a long page of testers from fetching for every one at once.
30
+ *
31
+ * const ref = useRef<HTMLDivElement>(null);
32
+ * usePrefetchWhenNear(ref, loadOptions);
33
+ */
34
+ export default function usePrefetchWhenNear(ref, prefetch) {
35
+ const prefetchRef = useRef(prefetch);
36
+ prefetchRef.current = prefetch;
37
+ useEffect(() => {
38
+ const el = ref.current;
39
+ if (!el) return;
40
+ let idle = null;
41
+ const schedule = () => {
42
+ idle ??= onIdle(() => {
43
+ var _prefetchRef$current;
44
+ return (_prefetchRef$current = prefetchRef.current) === null || _prefetchRef$current === void 0 ? void 0 : _prefetchRef$current.call(prefetchRef);
45
+ });
46
+ };
47
+ if (typeof IntersectionObserver !== 'function') {
48
+ schedule();
49
+ return () => {
50
+ var _idle;
51
+ return (_idle = idle) === null || _idle === void 0 ? void 0 : _idle.cancel();
52
+ };
53
+ }
54
+ const observer = new IntersectionObserver(entries => {
55
+ if (entries.some(entry => entry.isIntersecting)) {
56
+ observer.disconnect();
57
+ schedule();
58
+ }
59
+ }, {
60
+ rootMargin: PREFETCH_ROOT_MARGIN
61
+ });
62
+ observer.observe(el);
63
+ return () => {
64
+ var _idle2;
65
+ observer.disconnect();
66
+ (_idle2 = idle) === null || _idle2 === void 0 ? void 0 : _idle2.cancel();
67
+ };
68
+ }, [ref]);
69
+ }
package/dist/fontdue.css CHANGED
@@ -4007,6 +4007,7 @@ input:disabled + .fontdue-admin-toolbar__switch {
4007
4007
 
4008
4008
  fontdue-test-fonts-form,
4009
4009
  fontdue-newsletter-signup,
4010
- fontdue-customer-login-form {
4010
+ fontdue-customer-login-form,
4011
+ fontdue-customer-sign-in {
4011
4012
  font-size: 1rem;
4012
4013
  }
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  // (Fontage.ClientVersions) to spot sites that need to upgrade before a
9
9
  // feature that depends on a newer client can work for them.
10
10
  export const CLIENT_VERSION_HEADER = 'fontdue-client-version';
11
- export const version = "3.12.0";
11
+ export const version = "3.12.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.12.0",
3
+ "version": "3.12.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",