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.
- package/CHANGELOG.md +10 -0
- package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.d.ts +2 -1
- package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.js +1 -1
- package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.d.ts +21 -0
- package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.js +86 -0
- package/dist/__generated__/CustomerSignInElementQuery.graphql.d.ts +22 -0
- package/dist/__generated__/CustomerSignInElementQuery.graphql.js +87 -0
- package/dist/__tests__/customerLoginRecaptcha.test.js +113 -0
- package/dist/__tests__/customerSignInElement.test.js +155 -0
- package/dist/__tests__/customerSignInRequest.test.js +85 -0
- package/dist/__tests__/stripeCheckoutError.test.js +110 -0
- package/dist/__tests__/typeTesterHeightAnimation.test.js +124 -0
- package/dist/__tests__/typeTesterPrefetchStyles.test.js +108 -0
- package/dist/__tests__/typeTesterShyFeaturesClose.test.js +133 -0
- package/dist/components/CustomerLoginForm/index.js +106 -11
- package/dist/components/CustomerSignIn/CustomerSignInElement.d.ts +22 -0
- package/dist/components/CustomerSignIn/CustomerSignInElement.js +132 -0
- package/dist/components/CustomerSignIn/index.js +4 -2
- package/dist/components/Root/index.js +2 -0
- package/dist/components/StripeProvider/index.js +19 -2
- package/dist/components/TypeTester/TypeTesterFeatures.js +27 -44
- package/dist/components/TypeTester/TypeTesterStyleSelect.js +6 -1
- package/dist/components/TypeTester/useCloseWhenToolbarHides.d.ts +17 -0
- package/dist/components/TypeTester/useCloseWhenToolbarHides.js +79 -0
- package/dist/components/TypeTester/useHeightAnimation.d.ts +27 -0
- package/dist/components/TypeTester/useHeightAnimation.js +85 -0
- package/dist/components/TypeTester/usePrefetchWhenNear.d.ts +14 -0
- package/dist/components/TypeTester/usePrefetchWhenNear.js +69 -0
- package/dist/fontdue.css +2 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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$
|
|
181
|
+
var _data$viewer2, _data$viewer2$setting;
|
|
87
182
|
const data = useLazyLoadQuery(query, {});
|
|
88
|
-
const submittedLabel = ((_data$
|
|
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 {};
|