fontdue-js 3.11.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.d.ts +2 -1
  3. package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.js +1 -1
  4. package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.d.ts +21 -0
  5. package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.js +86 -0
  6. package/dist/__generated__/CustomerSignInElementQuery.graphql.d.ts +22 -0
  7. package/dist/__generated__/CustomerSignInElementQuery.graphql.js +87 -0
  8. package/dist/__tests__/customerLoginRecaptcha.test.js +113 -0
  9. package/dist/__tests__/customerSignInElement.test.js +155 -0
  10. package/dist/__tests__/customerSignInRequest.test.js +85 -0
  11. package/dist/__tests__/stripeCheckoutError.test.js +110 -0
  12. package/dist/__tests__/typeTesterHeightAnimation.test.js +124 -0
  13. package/dist/__tests__/typeTesterPrefetchStyles.test.js +108 -0
  14. package/dist/__tests__/typeTesterShyFeaturesClose.test.js +133 -0
  15. package/dist/components/CustomerLoginForm/index.js +106 -11
  16. package/dist/components/CustomerSignIn/CustomerSignInElement.d.ts +22 -0
  17. package/dist/components/CustomerSignIn/CustomerSignInElement.js +132 -0
  18. package/dist/components/CustomerSignIn/index.js +4 -2
  19. package/dist/components/Root/index.js +2 -0
  20. package/dist/components/StripeProvider/index.js +19 -2
  21. package/dist/components/TypeTester/TypeTesterFeatures.js +27 -44
  22. package/dist/components/TypeTester/TypeTesterStyleSelect.js +6 -1
  23. package/dist/components/TypeTester/useCloseWhenToolbarHides.d.ts +17 -0
  24. package/dist/components/TypeTester/useCloseWhenToolbarHides.js +79 -0
  25. package/dist/components/TypeTester/useHeightAnimation.d.ts +27 -0
  26. package/dist/components/TypeTester/useHeightAnimation.js +85 -0
  27. package/dist/components/TypeTester/usePrefetchWhenNear.d.ts +14 -0
  28. package/dist/components/TypeTester/usePrefetchWhenNear.js +69 -0
  29. package/dist/fontdue.css +2 -1
  30. package/dist/version.js +1 -1
  31. package/package.json +1 -1
@@ -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,124 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useHeightAnimation from '../components/TypeTester/useHeightAnimation.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ // happy-dom has no layout and no Web Animations, so both are stubbed: the
9
+ // panel's content is CONTENT_HEIGHT tall, an explicit px height measures as
10
+ // itself, and `midAnimation` stands in for the frame a running animation is on.
11
+ const CONTENT_HEIGHT = 120;
12
+ let midAnimation = null;
13
+ let runs = [];
14
+ function Panel(_ref) {
15
+ let {
16
+ open
17
+ } = _ref;
18
+ const {
19
+ ref,
20
+ style
21
+ } = useHeightAnimation(open, 300);
22
+ return /*#__PURE__*/React.createElement("div", {
23
+ className: "panel",
24
+ ref: ref,
25
+ style: style
26
+ });
27
+ }
28
+ describe('useHeightAnimation', () => {
29
+ let root;
30
+ let container;
31
+ const panel = () => container.querySelector('.panel');
32
+ const render = open => act(() => root.render( /*#__PURE__*/React.createElement(Panel, {
33
+ open: open
34
+ })));
35
+ const heights = run => run.keyframes.map(k => k.height);
36
+ beforeEach(() => {
37
+ runs = [];
38
+ midAnimation = null;
39
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function () {
40
+ const {
41
+ height
42
+ } = this.style;
43
+ const value = midAnimation ?? (height === 'auto' || height === '' ? CONTENT_HEIGHT : parseFloat(height));
44
+ return {
45
+ height: value
46
+ };
47
+ });
48
+ Element.prototype.animate = function (keyframes) {
49
+ const run = {
50
+ keyframes,
51
+ cancel: vi.fn(),
52
+ onfinish: null
53
+ };
54
+ runs.push(run);
55
+ return run;
56
+ };
57
+ container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ root = createRoot(container);
60
+ });
61
+ afterEach(() => {
62
+ act(() => root.unmount());
63
+ container.remove();
64
+ vi.restoreAllMocks();
65
+ delete Element.prototype.animate;
66
+ });
67
+ it('starts closed without animating', async () => {
68
+ await render(false);
69
+ expect(panel().style.height).toBe('0px');
70
+ expect(panel().style.overflow).toBe('hidden');
71
+ expect(runs).toHaveLength(0);
72
+ });
73
+ it('opens from 0 to the content height, then settles to auto and visible', async () => {
74
+ await render(false);
75
+ await render(true);
76
+ expect(runs).toHaveLength(1);
77
+ expect(heights(runs[0])).toEqual(['0px', `${CONTENT_HEIGHT}px`]);
78
+ expect(panel().style.overflow).toBe('hidden');
79
+ act(() => {
80
+ var _runs$0$onfinish, _runs$;
81
+ return (_runs$0$onfinish = (_runs$ = runs[0]).onfinish) === null || _runs$0$onfinish === void 0 ? void 0 : _runs$0$onfinish.call(_runs$);
82
+ });
83
+ expect(panel().style.height).toBe('auto');
84
+ expect(panel().style.overflow).toBe('visible');
85
+ });
86
+ it('closes from the open height to 0, and stays clipped', async () => {
87
+ await render(true);
88
+ await render(false);
89
+ expect(heights(runs[0])).toEqual([`${CONTENT_HEIGHT}px`, '0px']);
90
+ act(() => {
91
+ var _runs$0$onfinish2, _runs$2;
92
+ return (_runs$0$onfinish2 = (_runs$2 = runs[0]).onfinish) === null || _runs$0$onfinish2 === void 0 ? void 0 : _runs$0$onfinish2.call(_runs$2);
93
+ });
94
+ expect(panel().style.height).toBe('0px');
95
+ expect(panel().style.overflow).toBe('hidden');
96
+ });
97
+ it('reverses an interrupted open from the height on screen', async () => {
98
+ await render(false);
99
+ await render(true);
100
+ const opening = runs[0];
101
+ midAnimation = 60;
102
+ await render(false);
103
+ expect(opening.cancel).toHaveBeenCalled();
104
+ expect(heights(runs[1])).toEqual(['60px', '0px']);
105
+
106
+ // A late finish from the cancelled open must not unclip the closed panel.
107
+ act(() => {
108
+ var _opening$onfinish;
109
+ return (_opening$onfinish = opening.onfinish) === null || _opening$onfinish === void 0 ? void 0 : _opening$onfinish.call(opening);
110
+ });
111
+ expect(panel().style.overflow).toBe('hidden');
112
+ });
113
+ it('skips the animation for reduced motion', async () => {
114
+ vi.stubGlobal('matchMedia', () => ({
115
+ matches: true
116
+ }));
117
+ await render(false);
118
+ await render(true);
119
+ expect(runs).toHaveLength(0);
120
+ expect(panel().style.height).toBe('auto');
121
+ expect(panel().style.overflow).toBe('visible');
122
+ vi.unstubAllGlobals();
123
+ });
124
+ });
@@ -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,133 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useRef, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useCloseWhenToolbarHides, { CLOSE_DELAY } from '../components/TypeTester/useCloseWhenToolbarHides.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+ let open = false;
8
+
9
+ // The tester's markup reduced to what the hook reads: the `.type-tester` root,
10
+ // something focusable inside it (the editable text) and the surface's element.
11
+ function Tester(_ref) {
12
+ let {
13
+ shy
14
+ } = _ref;
15
+ const [featuresOpen, setFeaturesOpen] = useState(true);
16
+ open = featuresOpen;
17
+ const ref = useRef(null);
18
+ useCloseWhenToolbarHides(ref, {
19
+ open: featuresOpen,
20
+ setOpen: setFeaturesOpen,
21
+ shy
22
+ });
23
+ return /*#__PURE__*/React.createElement("div", {
24
+ className: "type-tester"
25
+ }, /*#__PURE__*/React.createElement("input", {
26
+ className: "text"
27
+ }), /*#__PURE__*/React.createElement("div", {
28
+ className: "type-tester__features-container",
29
+ ref: ref
30
+ }));
31
+ }
32
+ describe('features surface under a shy toolbar', () => {
33
+ let root;
34
+ let container;
35
+ let outside;
36
+ const tester = () => container.querySelector('.type-tester');
37
+ const text = () => container.querySelector('.text');
38
+ const pointer = function (target, type) {
39
+ let pointerType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'mouse';
40
+ return act(() => {
41
+ target.dispatchEvent(new PointerEvent(type, {
42
+ pointerType,
43
+ bubbles: true
44
+ }));
45
+ });
46
+ };
47
+ const wait = ms => act(() => vi.advanceTimersByTime(ms));
48
+ const render = shy => act(() => root.render( /*#__PURE__*/React.createElement(Tester, {
49
+ shy: shy
50
+ })));
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ container = document.createElement('div');
54
+ outside = document.createElement('button');
55
+ document.body.append(container, outside);
56
+ root = createRoot(container);
57
+ });
58
+ afterEach(() => {
59
+ act(() => root.unmount());
60
+ container.remove();
61
+ outside.remove();
62
+ vi.useRealTimers();
63
+ });
64
+ describe("shy: 'hover'", () => {
65
+ beforeEach(async () => {
66
+ await render('hover');
67
+ await pointer(tester(), 'pointerenter');
68
+ });
69
+ it('closes once the pointer has been gone for the delay', async () => {
70
+ await pointer(tester(), 'pointerleave');
71
+ await wait(CLOSE_DELAY - 1);
72
+ expect(open).toBe(true);
73
+ await wait(1);
74
+ expect(open).toBe(false);
75
+ });
76
+ it('stays open when the pointer comes back within the delay', async () => {
77
+ await pointer(tester(), 'pointerleave');
78
+ await wait(CLOSE_DELAY - 50);
79
+ await pointer(tester(), 'pointerenter');
80
+ await wait(CLOSE_DELAY);
81
+ expect(open).toBe(true);
82
+ });
83
+ it('stays open while the tester holds focus, then closes when focus leaves', async () => {
84
+ act(() => text().focus());
85
+ await pointer(tester(), 'pointerleave');
86
+ await wait(CLOSE_DELAY);
87
+ expect(open).toBe(true);
88
+ act(() => outside.focus());
89
+ await wait(CLOSE_DELAY);
90
+ expect(open).toBe(false);
91
+ });
92
+ it('stays open when focus leaves while the pointer is still over it', async () => {
93
+ act(() => text().focus());
94
+ act(() => outside.focus());
95
+ await wait(CLOSE_DELAY);
96
+ expect(open).toBe(true);
97
+ });
98
+ });
99
+ it('on a touch device (no hover), closes on a touch outside the tester', async () => {
100
+ await render('hover');
101
+ await pointer(tester(), 'pointerleave', 'touch');
102
+ await wait(CLOSE_DELAY);
103
+ expect(open).toBe(true);
104
+ await pointer(text(), 'pointerdown', 'touch');
105
+ await wait(CLOSE_DELAY);
106
+ expect(open).toBe(true);
107
+ await pointer(outside, 'pointerdown', 'touch');
108
+ await wait(CLOSE_DELAY);
109
+ expect(open).toBe(false);
110
+ });
111
+ it('treats shy: true as hover', async () => {
112
+ await render(true);
113
+ await pointer(tester(), 'pointerenter');
114
+ await pointer(tester(), 'pointerleave');
115
+ await wait(CLOSE_DELAY);
116
+ expect(open).toBe(false);
117
+ });
118
+ it("closes under shy: 'focus' when focus leaves, even while hovered", async () => {
119
+ await render('focus');
120
+ await pointer(tester(), 'pointerenter');
121
+ act(() => text().focus());
122
+ act(() => outside.focus());
123
+ await wait(CLOSE_DELAY);
124
+ expect(open).toBe(false);
125
+ });
126
+ it('leaves the surface alone without shy, where its toggle stays visible', async () => {
127
+ await render(false);
128
+ await pointer(tester(), 'pointerenter');
129
+ await pointer(tester(), 'pointerleave');
130
+ await wait(CLOSE_DELAY);
131
+ expect(open).toBe(true);
132
+ });
133
+ });
@@ -1,13 +1,19 @@
1
1
  'use client';
2
2
 
3
+ import _CustomerLoginFormRecaptchaQuery from "../../__generated__/CustomerLoginFormRecaptchaQuery.graphql.js";
3
4
  import _CustomerLoginFormQuery from "../../__generated__/CustomerLoginFormQuery.graphql.js";
4
5
  import _CustomerLoginFormLoginMutation from "../../__generated__/CustomerLoginFormLoginMutation.graphql.js";
5
- import React, { Suspense, useState } from 'react';
6
+ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
6
7
  import { commitMutation, graphql, useLazyLoadQuery, useRelayEnvironment } from 'react-relay';
8
+ // The script-injecting reCAPTCHA wrapper (loads Google's api.js); see
9
+ // ../Recaptcha for why it isn't the package's named export.
10
+ import { ReCAPTCHA } from '../Recaptcha.js';
7
11
  import TextField from '../TextField/index.js';
12
+ import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
8
13
  const loginMutation = (_CustomerLoginFormLoginMutation.hash && _CustomerLoginFormLoginMutation.hash !== "975b639fac1e0d88e0f0c9c55acd8b9c" && console.error("The definition of 'CustomerLoginFormLoginMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormLoginMutation);
9
14
  const query = (_CustomerLoginFormQuery.hash && _CustomerLoginFormQuery.hash !== "f32c7b0b485a2b2a02ddf2cbc5b87a3c" && console.error("The definition of 'CustomerLoginFormQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormQuery);
10
- const DEFAULT_SUBMITTED_LABEL = '<p>Submitted!</p><p>Please check your email inbox for a link to log in.</p><p>If you don\u2019t receive an email, please contact us to retrieve your order information.</p>';
15
+ const recaptchaQuery = (_CustomerLoginFormRecaptchaQuery.hash && _CustomerLoginFormRecaptchaQuery.hash !== "fe7289e19bcc08dbad62e72d67c48c97" && console.error("The definition of 'CustomerLoginFormRecaptchaQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormRecaptchaQuery);
16
+ const DEFAULT_SUBMITTED_LABEL = '<p>Submitted!</p><p>Please check your email inbox for a link to log in.</p><p>If you don’t receive an email, please contact us to retrieve your order information.</p>';
11
17
  const CustomerLoginForm = _ref => {
12
18
  let {
13
19
  submitLabel = 'Submit'
@@ -16,24 +22,40 @@ const CustomerLoginForm = _ref => {
16
22
  const [error, setError] = useState(null);
17
23
  const [submitting, setSubmitting] = useState(false);
18
24
  const [submitted, setSubmitted] = useState(false);
25
+ // The store's reCAPTCHA site key once known: null when it doesn't use
26
+ // reCAPTCHA. Undefined until the settings arrive, which is only after mount.
27
+ const [siteKey, setSiteKey] = useState(undefined);
28
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
29
+ const [pendingSubmit, setPendingSubmit] = useState(false);
30
+ const [mounted, setMounted] = useState(false);
31
+ const recaptchaRef = useRef(null);
32
+ const recaptchaTimeout = useRecaptchaTimeout();
19
33
  const environment = useRelayEnvironment();
20
- const handleSubmit = e => {
21
- e.preventDefault();
34
+ useEffect(() => setMounted(true), []);
35
+ const resetRecaptcha = useCallback(() => {
36
+ var _recaptchaRef$current;
37
+ (_recaptchaRef$current = recaptchaRef.current) === null || _recaptchaRef$current === void 0 ? void 0 : _recaptchaRef$current.reset();
38
+ setRecaptchaToken(null);
39
+ }, []);
40
+ const submitForm = useCallback(token => {
41
+ recaptchaTimeout.clear();
42
+ setPendingSubmit(false);
22
43
  setSubmitting(true);
23
44
  commitMutation(environment, {
24
45
  mutation: loginMutation,
25
46
  variables: {
26
47
  input: {
27
- email
48
+ email,
49
+ recaptchaToken: token ?? undefined
28
50
  }
29
51
  },
30
52
  onCompleted: (_res, errors) => {
53
+ setSubmitting(false);
31
54
  if (errors && errors.length > 0) {
32
55
  setError(errors[0].message);
33
- setSubmitting(false);
56
+ resetRecaptcha();
34
57
  } else {
35
58
  setSubmitted(true);
36
- setSubmitting(false);
37
59
  setError(null);
38
60
  }
39
61
  },
@@ -43,8 +65,46 @@ const CustomerLoginForm = _ref => {
43
65
  } = _ref2;
44
66
  setError(message);
45
67
  setSubmitting(false);
68
+ resetRecaptcha();
46
69
  }
47
70
  });
71
+ }, [environment, email, recaptchaTimeout, resetRecaptcha]);
72
+ const handleRecaptchaChange = useCallback(token => {
73
+ setRecaptchaToken(token);
74
+ if (token && pendingSubmit) submitForm(token);
75
+ }, [pendingSubmit, submitForm]);
76
+ const handleRecaptchaExpired = useCallback(() => setRecaptchaToken(null), []);
77
+
78
+ // Recover when the widget can't produce a token, so the button doesn't sit
79
+ // on “Submitting…” forever if Google's script fails to load.
80
+ const abortRecaptcha = useCallback(() => {
81
+ recaptchaTimeout.clear();
82
+ setPendingSubmit(false);
83
+ resetRecaptcha();
84
+ setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
85
+ }, [recaptchaTimeout, resetRecaptcha]);
86
+ const handleRecaptchaErrored = useCallback(() => {
87
+ if (pendingSubmit) abortRecaptcha();
88
+ }, [pendingSubmit, abortRecaptcha]);
89
+ const handleSubmit = e => {
90
+ e.preventDefault();
91
+ setError(null);
92
+ if (siteKey && !recaptchaToken) {
93
+ var _recaptchaRef$current2;
94
+ setPendingSubmit(true);
95
+ // Give up if no token arrives and the script never loaded; a visitor
96
+ // mid-challenge keeps `grecaptcha` defined and isn't cut off.
97
+ recaptchaTimeout.start(() => {
98
+ if (isRecaptchaScriptLoaded()) return;
99
+ abortRecaptcha();
100
+ });
101
+ (_recaptchaRef$current2 = recaptchaRef.current) === null || _recaptchaRef$current2 === void 0 ? void 0 : _recaptchaRef$current2.execute();
102
+ return;
103
+ }
104
+
105
+ // Settings not in yet (a submit within moments of the page loading) goes
106
+ // without a token; the server says so if the store needs one.
107
+ submitForm(recaptchaToken);
48
108
  };
49
109
  const disabled = !email;
50
110
  return /*#__PURE__*/React.createElement("div", {
@@ -69,23 +129,58 @@ const CustomerLoginForm = _ref => {
69
129
  onChange: value => setEmail(value),
70
130
  autocomplete: "email",
71
131
  placeholder: "Your email"
132
+ })), mounted && /*#__PURE__*/React.createElement(Suspense, {
133
+ fallback: null
134
+ }, /*#__PURE__*/React.createElement(LoginRecaptcha, {
135
+ widgetRef: recaptchaRef,
136
+ onSiteKey: setSiteKey,
137
+ onChange: handleRecaptchaChange,
138
+ onExpired: handleRecaptchaExpired,
139
+ onErrored: handleRecaptchaErrored
72
140
  })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
73
141
  className: "submit-button",
74
142
  type: "submit",
75
- disabled: submitting || disabled
76
- }, submitting ? 'Submitting...' : submitLabel, /*#__PURE__*/React.createElement("span", {
143
+ disabled: submitting || pendingSubmit || disabled
144
+ }, submitting || pendingSubmit ? 'Submitting...' : submitLabel, /*#__PURE__*/React.createElement("span", {
77
145
  className: "submit-button__arrow"
78
146
  }, " \u2192")))));
79
147
  };
80
148
 
149
+ // The store's reCAPTCHA settings and, when it uses reCAPTCHA, the invisible
150
+ // widget. Rendered only after mount, so the lazy query stays out of the SSR
151
+ // pass for the same reason as SubmittedMessage below.
152
+ function LoginRecaptcha(_ref3) {
153
+ var _data$viewer;
154
+ let {
155
+ widgetRef,
156
+ onSiteKey,
157
+ onChange,
158
+ onExpired,
159
+ onErrored
160
+ } = _ref3;
161
+ const data = useLazyLoadQuery(recaptchaQuery, {});
162
+ const settings = (_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : _data$viewer.settings;
163
+ const siteKey = settings !== null && settings !== void 0 && settings.recaptchaEnabled ? settings.recaptchaSiteKey ?? null : null;
164
+ useEffect(() => onSiteKey(siteKey), [siteKey, onSiteKey]);
165
+ if (!siteKey) return null;
166
+ return /*#__PURE__*/React.createElement(ReCAPTCHA, {
167
+ ref: widgetRef,
168
+ sitekey: siteKey,
169
+ size: "invisible",
170
+ onChange: onChange,
171
+ onExpired: onExpired,
172
+ onErrored: onErrored
173
+ });
174
+ }
175
+
81
176
  // The settings label is only shown after a submit, which can only happen in
82
177
  // the browser — fetching it here (not in the form component) keeps the lazy
83
178
  // query out of the SSR pass, where it has no session and, in multi-tenant
84
179
  // setups, possibly no resolvable GraphQL URL.
85
180
  function SubmittedMessage() {
86
- var _data$viewer, _data$viewer$settings;
181
+ var _data$viewer2, _data$viewer2$setting;
87
182
  const data = useLazyLoadQuery(query, {});
88
- const submittedLabel = ((_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : (_data$viewer$settings = _data$viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.customerLoginSubmittedLabel) || DEFAULT_SUBMITTED_LABEL;
183
+ const submittedLabel = ((_data$viewer2 = data.viewer) === null || _data$viewer2 === void 0 ? void 0 : (_data$viewer2$setting = _data$viewer2.settings) === null || _data$viewer2$setting === void 0 ? void 0 : _data$viewer2$setting.customerLoginSubmittedLabel) || DEFAULT_SUBMITTED_LABEL;
89
184
  return /*#__PURE__*/React.createElement("div", {
90
185
  className: "login-form__submitted",
91
186
  dangerouslySetInnerHTML: {
@@ -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 {};