fontdue-js 3.2.0 → 3.2.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 +7 -0
- package/dist/__tests__/nextAdapter.test.js +63 -3
- package/dist/__tests__/recaptchaTimeout.test.js +191 -0
- package/dist/components/ConnectionErrorToolbar.js +2 -2
- package/dist/components/FontdueAdminToolbar/index.js +23 -11
- package/dist/components/NewsletterSignup/index.js +33 -5
- package/dist/components/NodePasswordForm/index.js +9 -4
- package/dist/components/Recaptcha.d.ts +3 -0
- package/dist/components/Recaptcha.js +25 -0
- package/dist/components/TestFontsForm/index.js +33 -5
- package/dist/components/TypeTester/TypeTesterStandaloneElement.js +9 -4
- package/dist/components/TypeTester/TypeTesterVariableAxes.js +16 -0
- package/dist/components/elements/StoreModalUnifiedCheckout.js +48 -6
- package/dist/fontdue.css +149 -25
- package/dist/hooks/useRecaptchaTimeout.d.ts +11 -0
- package/dist/hooks/useRecaptchaTimeout.js +60 -0
- package/dist/next/unlock.js +64 -1
- package/dist/relay/environment.js +1 -1
- package/package.json +1 -1
- package/types/next-headers.d.ts +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 3.2.1
|
|
2
|
+
|
|
3
|
+
- **Fixed checkout, newsletter signup and trial-font downloads hanging forever on reCAPTCHA-enabled foundries.** `StoreModalUnifiedCheckout`, `NewsletterSignup` and `TestFontsForm` imported the *named* `ReCAPTCHA` export from `react-google-recaptcha` — the barebone widget, which never injects Google's `api.js` and expects you to supply `grecaptcha` yourself. So the invisible widget rendered an empty `<div>`, `window.grecaptcha` stayed undefined, `execute()` was a permanent silent no-op, and the submit button froze on "Submitting…" with no network request and no error. All three forms now use the package's default export (the `react-async-script` wrapper that actually loads the script). This has affected every release since 3.0.0. As defence in depth, each form also arms a 15-second timeout after calling `execute()`: if the reCAPTCHA script never loaded, the form resets and surfaces an inline error so the buyer can retry, while a challenge already in progress is never cancelled. No public API change — no new or changed props.
|
|
4
|
+
- **Redesigned the admin preview toolbar** (`FontdueAdminToolbar`) — the collapsed control is now a bottom-centre pill carrying its state in the label (*Preview* / *Previewing hidden fonts* / *Preview expired*), and the panel leads with the *Hidden fonts* switch. The toolbar chrome no longer inherits the host page's font. If you style the toolbar yourself, note the new element class names (`__brand`, `__mark`, `__toggle-text`, `__switch`, `__status`) and that `__dot` is gone; `data-testid="preview-toggle"` is unchanged.
|
|
5
|
+
- `TypeTester` standalone element: comma-separated `axes`, `features` and `features-selected` attributes are now trimmed, so `axes="wght, opsz"` behaves the same as `axes="wght,opsz"` and a trailing comma no longer creates an empty entry (`features="*"` is preserved). Requesting an axis the bound style doesn't expose now logs a console warning naming the axes it does expose, instead of silently rendering no sliders.
|
|
6
|
+
- **Unlocking a password-protected collection no longer slows down the rest of the site for that visitor.** The unlock route enabled Next draft mode with its default site-wide bypass cookie (`Path=/`), so after one unlock *every* page the visitor touched skipped the full-route cache and the data cache and was server-rendered live — seconds per page on content-heavy pages. `NodePasswordForm` now sends the pathname it unlocked on, and the unlock route scopes the bypass cookie to it: only the unlocked collection's pages render dynamically for that visitor, and everything else keeps serving from the static cache. Unlocking several collections yields several path-scoped cookies that coexist. Admin preview is unchanged — its bypass stays site-wide, which is the point of previewing.
|
|
7
|
+
|
|
1
8
|
## 3.2.0
|
|
2
9
|
|
|
3
10
|
- **Fixed password-protected collections crashing statically-rendered Next.js storefronts.** Locked slugs are excluded from `generateStaticParams`, so a visitor's first request for one triggers an on-demand static fill of the font page — and the unlock-cookie read during that fill crashed it with a 500 (digest `DYNAMIC_SERVER_USAGE`). A locked page's static fill now renders (and caches) the password form, and unlocking takes that visitor past the static cache instead — the same mechanism as admin preview:
|
|
@@ -44,18 +44,31 @@ vi.mock('next/navigation', () => ({
|
|
|
44
44
|
// Draft mode + the preview token cookie, controllable per test. Default: not
|
|
45
45
|
// previewing, so __prepareFontdueRender takes the public (cached) path. Set
|
|
46
46
|
// cookiesError to make cookies() throw (e.g. simulate Next's dynamic bailout
|
|
47
|
-
// during a prerender pass).
|
|
47
|
+
// during a prerender pass). The jar mirrors Next's mutable route-handler
|
|
48
|
+
// cookie store: draftMode().enable() queues the site-wide bypass cookie in it
|
|
49
|
+
// (exactly the attributes real enable() sets), cookies().set() with the same
|
|
50
|
+
// name replaces the queued cookie, and cookies().get() sees pending
|
|
51
|
+
// mutations — the semantics the unlock route's path-scoping relies on.
|
|
48
52
|
const draft = vi.hoisted(() => ({
|
|
49
53
|
enabled: false,
|
|
50
54
|
token: undefined,
|
|
51
55
|
nodeAccess: undefined,
|
|
52
|
-
cookiesError: undefined
|
|
56
|
+
cookiesError: undefined,
|
|
57
|
+
jar: new Map()
|
|
53
58
|
}));
|
|
54
59
|
vi.mock('next/headers', () => ({
|
|
55
60
|
draftMode: async () => ({
|
|
56
61
|
isEnabled: draft.enabled,
|
|
57
62
|
enable: () => {
|
|
58
63
|
draft.enabled = true;
|
|
64
|
+
draft.jar.set('__prerender_bypass', {
|
|
65
|
+
name: '__prerender_bypass',
|
|
66
|
+
value: 'test-preview-mode-id',
|
|
67
|
+
httpOnly: true,
|
|
68
|
+
sameSite: 'none',
|
|
69
|
+
secure: true,
|
|
70
|
+
path: '/'
|
|
71
|
+
});
|
|
59
72
|
}
|
|
60
73
|
}),
|
|
61
74
|
cookies: async () => {
|
|
@@ -65,7 +78,10 @@ vi.mock('next/headers', () => ({
|
|
|
65
78
|
value: draft.token
|
|
66
79
|
} : name === 'fontdue_node_access' && draft.nodeAccess ? {
|
|
67
80
|
value: draft.nodeAccess
|
|
68
|
-
} :
|
|
81
|
+
} : draft.jar.get(name),
|
|
82
|
+
set: cookie => {
|
|
83
|
+
draft.jar.set(cookie.name, cookie);
|
|
84
|
+
}
|
|
69
85
|
};
|
|
70
86
|
}
|
|
71
87
|
}));
|
|
@@ -82,6 +98,7 @@ beforeEach(() => {
|
|
|
82
98
|
draft.token = undefined;
|
|
83
99
|
draft.nodeAccess = undefined;
|
|
84
100
|
draft.cookiesError = undefined;
|
|
101
|
+
draft.jar.clear();
|
|
85
102
|
});
|
|
86
103
|
function stubSingleTenant() {
|
|
87
104
|
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
@@ -457,6 +474,49 @@ describe('unlock route handler', () => {
|
|
|
457
474
|
});
|
|
458
475
|
expect(draft.enabled).toBe(true);
|
|
459
476
|
});
|
|
477
|
+
it('scopes the bypass cookie to the posted path, replacing the site-wide one', async () => {
|
|
478
|
+
const {
|
|
479
|
+
POST
|
|
480
|
+
} = await importUnlock();
|
|
481
|
+
const response = await POST(unlockRequest(JSON.stringify({
|
|
482
|
+
token: 'na-tok',
|
|
483
|
+
path: '/fonts/recursive'
|
|
484
|
+
})));
|
|
485
|
+
expect(response.status).toBe(200);
|
|
486
|
+
expect(draft.enabled).toBe(true);
|
|
487
|
+
const bypass = draft.jar.get('__prerender_bypass');
|
|
488
|
+
expect(bypass === null || bypass === void 0 ? void 0 : bypass.path).toBe('/fonts/recursive');
|
|
489
|
+
// Replaced, not duplicated — and the value + protective attributes from
|
|
490
|
+
// enable() survive the re-set.
|
|
491
|
+
expect(bypass === null || bypass === void 0 ? void 0 : bypass.value).toBe('test-preview-mode-id');
|
|
492
|
+
expect(bypass === null || bypass === void 0 ? void 0 : bypass.httpOnly).toBe(true);
|
|
493
|
+
expect(bypass === null || bypass === void 0 ? void 0 : bypass.sameSite).toBe('none');
|
|
494
|
+
expect(bypass === null || bypass === void 0 ? void 0 : bypass.secure).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
it('strips trailing slashes so the cookie still matches the page itself', async () => {
|
|
497
|
+
var _draft$jar$get;
|
|
498
|
+
const {
|
|
499
|
+
POST
|
|
500
|
+
} = await importUnlock();
|
|
501
|
+
await POST(unlockRequest(JSON.stringify({
|
|
502
|
+
token: 'na-tok',
|
|
503
|
+
path: '/fonts/recursive/'
|
|
504
|
+
})));
|
|
505
|
+
expect((_draft$jar$get = draft.jar.get('__prerender_bypass')) === null || _draft$jar$get === void 0 ? void 0 : _draft$jar$get.path).toBe('/fonts/recursive');
|
|
506
|
+
});
|
|
507
|
+
it.each([['missing', undefined], ['not a string', 42], ['relative', 'fonts/recursive'], ['attribute injection', '/fonts/x; SameSite=Lax'], ['not percent-encoded (space)', '/fonts/ x'], ['control characters', '/fonts/\u0000x'], ['root (nothing to narrow)', '/'], ['over-long', '/' + 'x'.repeat(600)]])('leaves the bypass site-wide when the path is %s', async (_label, path) => {
|
|
508
|
+
var _draft$jar$get2;
|
|
509
|
+
const {
|
|
510
|
+
POST
|
|
511
|
+
} = await importUnlock();
|
|
512
|
+
const response = await POST(unlockRequest(JSON.stringify({
|
|
513
|
+
token: 'na-tok',
|
|
514
|
+
path
|
|
515
|
+
})));
|
|
516
|
+
expect(response.status).toBe(200);
|
|
517
|
+
expect(draft.enabled).toBe(true);
|
|
518
|
+
expect((_draft$jar$get2 = draft.jar.get('__prerender_bypass')) === null || _draft$jar$get2 === void 0 ? void 0 : _draft$jar$get2.path).toBe('/');
|
|
519
|
+
});
|
|
460
520
|
it('rejects a missing token without enabling the bypass', async () => {
|
|
461
521
|
const {
|
|
462
522
|
POST
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_TIMEOUT_MS, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../hooks/useRecaptchaTimeout.js';
|
|
3
|
+
|
|
4
|
+
// FD-917: on a reCAPTCHA-enabled tenant the checkout / newsletter / test-fonts
|
|
5
|
+
// submit handlers call `recaptchaRef.current.execute()` and then wait for the
|
|
6
|
+
// widget's `onChange` to hand back a token. If the invisible widget never loads
|
|
7
|
+
// (Google's api.js is never injected — reproduced live on Big Fog Foundry),
|
|
8
|
+
// `execute()` is a silent no-op, `onChange` never fires, and the button sits on
|
|
9
|
+
// “Submitting…” forever with no error and no network request.
|
|
10
|
+
//
|
|
11
|
+
// The fix is a timeout guard (`createRecaptchaTimeout`) plus a readiness probe
|
|
12
|
+
// (`isRecaptchaScriptLoaded`). These tests model the submit control flow to
|
|
13
|
+
// pin the contract: a widget that never responds must not hang forever, and a
|
|
14
|
+
// widget that DID load (buyer possibly mid-challenge) must not be cancelled.
|
|
15
|
+
|
|
16
|
+
// Minimal model of a submit handler's reCAPTCHA-gated path, mirroring
|
|
17
|
+
// handleCustomerSubmit + handleRecaptchaChange in StoreModalUnifiedCheckout.
|
|
18
|
+
function makeSubmitHarness(_ref) {
|
|
19
|
+
let {
|
|
20
|
+
widgetEverLoads,
|
|
21
|
+
fireTokenAfterMs
|
|
22
|
+
} = _ref;
|
|
23
|
+
const timeout = createRecaptchaTimeout();
|
|
24
|
+
const state = {
|
|
25
|
+
submitting: false,
|
|
26
|
+
pending: false,
|
|
27
|
+
error: null,
|
|
28
|
+
submittedToken: undefined,
|
|
29
|
+
resets: 0
|
|
30
|
+
};
|
|
31
|
+
const onChange = token => {
|
|
32
|
+
if (token && state.pending) {
|
|
33
|
+
timeout.clear();
|
|
34
|
+
state.pending = false;
|
|
35
|
+
state.submitting = false;
|
|
36
|
+
state.submittedToken = token;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const abort = () => {
|
|
40
|
+
timeout.clear();
|
|
41
|
+
state.pending = false;
|
|
42
|
+
state.submitting = false;
|
|
43
|
+
state.resets += 1;
|
|
44
|
+
state.error = RECAPTCHA_UNAVAILABLE_MESSAGE;
|
|
45
|
+
};
|
|
46
|
+
const execute = () => {
|
|
47
|
+
if (widgetEverLoads) {
|
|
48
|
+
globalThis.grecaptcha = {
|
|
49
|
+
ready: true
|
|
50
|
+
};
|
|
51
|
+
if (fireTokenAfterMs != null) {
|
|
52
|
+
setTimeout(() => onChange('tok_ok'), fireTokenAfterMs);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Dead widget: execute() is a no-op, onChange never fires.
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Mirrors the guarded submit branch: mark submitting/pending, arm the safety
|
|
59
|
+
// net, then execute.
|
|
60
|
+
const submit = () => {
|
|
61
|
+
state.submitting = true;
|
|
62
|
+
state.pending = true;
|
|
63
|
+
state.error = null;
|
|
64
|
+
timeout.start(() => {
|
|
65
|
+
if (isRecaptchaScriptLoaded()) return; // may be mid-challenge; keep waiting
|
|
66
|
+
abort();
|
|
67
|
+
});
|
|
68
|
+
execute();
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
state,
|
|
72
|
+
submit
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
describe('reCAPTCHA resilience (FD-917)', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
delete globalThis.grecaptcha;
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
vi.useRealTimers();
|
|
82
|
+
delete globalThis.grecaptcha;
|
|
83
|
+
});
|
|
84
|
+
it('dead widget: resets and shows an error instead of hanging forever', () => {
|
|
85
|
+
const {
|
|
86
|
+
state,
|
|
87
|
+
submit
|
|
88
|
+
} = makeSubmitHarness({
|
|
89
|
+
widgetEverLoads: false
|
|
90
|
+
});
|
|
91
|
+
submit();
|
|
92
|
+
// Immediately after submit the button is stuck on “Submitting…”.
|
|
93
|
+
expect(state.submitting).toBe(true);
|
|
94
|
+
expect(state.pending).toBe(true);
|
|
95
|
+
expect(state.error).toBeNull();
|
|
96
|
+
|
|
97
|
+
// Advance well past any real challenge — the old code would still be stuck.
|
|
98
|
+
vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
|
|
99
|
+
expect(state.submitting).toBe(false);
|
|
100
|
+
expect(state.pending).toBe(false);
|
|
101
|
+
expect(state.resets).toBe(1);
|
|
102
|
+
expect(state.error).toBe(RECAPTCHA_UNAVAILABLE_MESSAGE);
|
|
103
|
+
});
|
|
104
|
+
it('happy path: a token before the timeout submits and never triggers the guard', () => {
|
|
105
|
+
const {
|
|
106
|
+
state,
|
|
107
|
+
submit
|
|
108
|
+
} = makeSubmitHarness({
|
|
109
|
+
widgetEverLoads: true,
|
|
110
|
+
fireTokenAfterMs: 1500
|
|
111
|
+
});
|
|
112
|
+
submit();
|
|
113
|
+
vi.advanceTimersByTime(1500);
|
|
114
|
+
expect(state.submittedToken).toBe('tok_ok');
|
|
115
|
+
expect(state.submitting).toBe(false);
|
|
116
|
+
expect(state.pending).toBe(false);
|
|
117
|
+
expect(state.error).toBeNull();
|
|
118
|
+
|
|
119
|
+
// The timeout must not fire after a successful submit.
|
|
120
|
+
vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
|
|
121
|
+
expect(state.resets).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
it('slow challenge: a loaded widget is not cancelled while awaiting the buyer', () => {
|
|
124
|
+
// Widget loaded (grecaptcha defined) but the token takes longer than the
|
|
125
|
+
// timeout — e.g. the buyer is solving an image challenge. We must keep
|
|
126
|
+
// waiting rather than resetting under them.
|
|
127
|
+
const {
|
|
128
|
+
state,
|
|
129
|
+
submit
|
|
130
|
+
} = makeSubmitHarness({
|
|
131
|
+
widgetEverLoads: true,
|
|
132
|
+
fireTokenAfterMs: RECAPTCHA_TIMEOUT_MS + 5000
|
|
133
|
+
});
|
|
134
|
+
submit();
|
|
135
|
+
// Cross the timeout while the challenge is still open.
|
|
136
|
+
vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
|
|
137
|
+
expect(state.resets).toBe(0);
|
|
138
|
+
expect(state.pending).toBe(true);
|
|
139
|
+
|
|
140
|
+
// The buyer finishes the challenge; the token still submits.
|
|
141
|
+
vi.advanceTimersByTime(4000);
|
|
142
|
+
expect(state.submittedToken).toBe('tok_ok');
|
|
143
|
+
expect(state.submitting).toBe(false);
|
|
144
|
+
expect(state.pending).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('createRecaptchaTimeout', () => {
|
|
148
|
+
beforeEach(() => vi.useFakeTimers());
|
|
149
|
+
afterEach(() => vi.useRealTimers());
|
|
150
|
+
it('fires onTimeout after the delay', () => {
|
|
151
|
+
const timeout = createRecaptchaTimeout(1000);
|
|
152
|
+
const cb = vi.fn();
|
|
153
|
+
timeout.start(cb);
|
|
154
|
+
vi.advanceTimersByTime(999);
|
|
155
|
+
expect(cb).not.toHaveBeenCalled();
|
|
156
|
+
vi.advanceTimersByTime(1);
|
|
157
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
it('clear() cancels a pending timer', () => {
|
|
160
|
+
const timeout = createRecaptchaTimeout(1000);
|
|
161
|
+
const cb = vi.fn();
|
|
162
|
+
timeout.start(cb);
|
|
163
|
+
timeout.clear();
|
|
164
|
+
vi.advanceTimersByTime(5000);
|
|
165
|
+
expect(cb).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it('start() replaces a previously scheduled timer', () => {
|
|
168
|
+
const timeout = createRecaptchaTimeout(1000);
|
|
169
|
+
const first = vi.fn();
|
|
170
|
+
const second = vi.fn();
|
|
171
|
+
timeout.start(first);
|
|
172
|
+
vi.advanceTimersByTime(500);
|
|
173
|
+
timeout.start(second);
|
|
174
|
+
vi.advanceTimersByTime(1000);
|
|
175
|
+
expect(first).not.toHaveBeenCalled();
|
|
176
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe('isRecaptchaScriptLoaded', () => {
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
delete globalThis.grecaptcha;
|
|
182
|
+
});
|
|
183
|
+
it('is false when grecaptcha is absent', () => {
|
|
184
|
+
delete globalThis.grecaptcha;
|
|
185
|
+
expect(isRecaptchaScriptLoaded()).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
it('is true once grecaptcha is present', () => {
|
|
188
|
+
globalThis.grecaptcha = {};
|
|
189
|
+
expect(isRecaptchaScriptLoaded()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -48,9 +48,9 @@ function ConnectionErrorToolbar(_ref) {
|
|
|
48
48
|
className: "fontdue-admin-toolbar__error-status-text"
|
|
49
49
|
}, /*#__PURE__*/React.createElement(WarningIcon, null), "Fontdue couldn\u2019t be reached from this site."), /*#__PURE__*/React.createElement("p", {
|
|
50
50
|
className: "fontdue-admin-toolbar__hint"
|
|
51
|
-
}, "This is most likely a cross-origin (CORS) setting \
|
|
51
|
+
}, "This is most likely a cross-origin (CORS) setting \u2013 ", origin, " isn\u2019t on Fontdue\u2019s allowed list. Add it under Settings \u2192 Integration and this page will reload automatically."), /*#__PURE__*/React.createElement("p", {
|
|
52
52
|
className: "fontdue-admin-toolbar__hint"
|
|
53
|
-
}, "If it\u2019s already listed, the connection may be failing for another reason \
|
|
53
|
+
}, "If it\u2019s already listed, the connection may be failing for another reason \u2013 a network problem or a temporary Fontdue outage."), fixUrl && /*#__PURE__*/React.createElement("a", {
|
|
54
54
|
className: "fontdue-admin-toolbar__action",
|
|
55
55
|
href: fixUrl,
|
|
56
56
|
target: "_blank",
|
|
@@ -139,7 +139,7 @@ export default function FontdueAdminToolbar() {
|
|
|
139
139
|
if (isSessionExpiredError(errors)) {
|
|
140
140
|
setPreviewMarkerCookie(false);
|
|
141
141
|
setPreviewState('off');
|
|
142
|
-
setError('Your session expired
|
|
142
|
+
setError('Your session expired, please sign in again.');
|
|
143
143
|
} else {
|
|
144
144
|
setError('Could not start preview.');
|
|
145
145
|
}
|
|
@@ -204,8 +204,8 @@ export default function FontdueAdminToolbar() {
|
|
|
204
204
|
}, /*#__PURE__*/React.createElement("div", {
|
|
205
205
|
className: "fontdue-admin-toolbar__header"
|
|
206
206
|
}, /*#__PURE__*/React.createElement("span", {
|
|
207
|
-
className: "fontdue-admin-
|
|
208
|
-
}, "
|
|
207
|
+
className: "fontdue-admin-toolbar__brand"
|
|
208
|
+
}, /*#__PURE__*/React.createElement(FontdueMark, null), "Preview"), /*#__PURE__*/React.createElement("span", {
|
|
209
209
|
className: "fontdue-admin-toolbar__user"
|
|
210
210
|
}, adminName)), expired ? /*#__PURE__*/React.createElement("div", {
|
|
211
211
|
className: "fontdue-admin-toolbar__expired",
|
|
@@ -221,15 +221,20 @@ export default function FontdueAdminToolbar() {
|
|
|
221
221
|
"data-testid": "reenter-preview-button"
|
|
222
222
|
}, "Re-enter preview")) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("label", {
|
|
223
223
|
className: "fontdue-admin-toolbar__toggle"
|
|
224
|
-
}, /*#__PURE__*/React.createElement("
|
|
224
|
+
}, /*#__PURE__*/React.createElement("span", {
|
|
225
|
+
className: "fontdue-admin-toolbar__toggle-text"
|
|
226
|
+
}, /*#__PURE__*/React.createElement("strong", null, "Hidden fonts"), /*#__PURE__*/React.createElement("span", null, "Show unpublished families, only to you")), /*#__PURE__*/React.createElement("input", {
|
|
225
227
|
type: "checkbox",
|
|
226
228
|
checked: previewing,
|
|
227
229
|
disabled: busy,
|
|
228
230
|
onChange: e => e.target.checked ? enterPreview() : exitPreview(),
|
|
229
231
|
"data-testid": "preview-toggle"
|
|
230
|
-
}), /*#__PURE__*/React.createElement("span",
|
|
231
|
-
className: "fontdue-admin-
|
|
232
|
-
|
|
232
|
+
}), /*#__PURE__*/React.createElement("span", {
|
|
233
|
+
className: "fontdue-admin-toolbar__switch",
|
|
234
|
+
"aria-hidden": "true"
|
|
235
|
+
})), previewing && /*#__PURE__*/React.createElement("p", {
|
|
236
|
+
className: "fontdue-admin-toolbar__status"
|
|
237
|
+
}, "Visible across the whole site until you switch it off or the preview expires.")), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
|
|
233
238
|
className: "fontdue-admin-toolbar__actions"
|
|
234
239
|
}, revalidateEndpoint && /*#__PURE__*/React.createElement("button", {
|
|
235
240
|
type: "button",
|
|
@@ -249,7 +254,7 @@ export default function FontdueAdminToolbar() {
|
|
|
249
254
|
className: "fontdue-admin-toolbar__error"
|
|
250
255
|
}, error), /*#__PURE__*/React.createElement("p", {
|
|
251
256
|
className: "fontdue-admin-toolbar__meta"
|
|
252
|
-
}, fontdueHost ? `Shown
|
|
257
|
+
}, fontdueHost ? `Shown only to you – you’re signed in to Fontdue at ${fontdueHost}.` : 'Shown only to you – you’re signed in to Fontdue.', /*#__PURE__*/React.createElement("span", {
|
|
253
258
|
className: "fontdue-admin-toolbar__version"
|
|
254
259
|
}, "fontdue-js ", version))), /*#__PURE__*/React.createElement("button", {
|
|
255
260
|
type: "button",
|
|
@@ -257,10 +262,17 @@ export default function FontdueAdminToolbar() {
|
|
|
257
262
|
onClick: () => setOpen(v => !v),
|
|
258
263
|
"aria-expanded": open,
|
|
259
264
|
"data-testid": "fontdue-admin-toolbar-button"
|
|
260
|
-
}, expired ? /*#__PURE__*/React.createElement(WarningIcon, null) : /*#__PURE__*/React.createElement(
|
|
261
|
-
|
|
265
|
+
}, expired ? /*#__PURE__*/React.createElement(WarningIcon, null) : /*#__PURE__*/React.createElement(FontdueMark, null), expired ? 'Preview expired' : previewing ? 'Previewing hidden fonts' : 'Preview'));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The Fontdue mark: a serif F on an iris square. Brands the pill and the panel
|
|
269
|
+
// header so the control can name its function ("Preview") instead of the
|
|
270
|
+
// vendor; the full attribution lives in the panel footer.
|
|
271
|
+
function FontdueMark() {
|
|
272
|
+
return /*#__PURE__*/React.createElement("span", {
|
|
273
|
+
className: "fontdue-admin-toolbar__mark",
|
|
262
274
|
"aria-hidden": "true"
|
|
263
|
-
}
|
|
275
|
+
}, "F");
|
|
264
276
|
}
|
|
265
277
|
|
|
266
278
|
// Small inline warning glyph (a triangle with an exclamation). Inherits the
|
|
@@ -5,13 +5,17 @@ import _NewsletterSignupQuery from "../../__generated__/NewsletterSignupQuery.gr
|
|
|
5
5
|
import _NewsletterSignupUpdateCustomerMutation from "../../__generated__/NewsletterSignupUpdateCustomerMutation.graphql.js";
|
|
6
6
|
import React, { useState, useRef, useCallback } from 'react';
|
|
7
7
|
import { graphql, commitMutation, useRelayEnvironment, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
|
|
8
|
-
|
|
8
|
+
// The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
|
|
9
|
+
// package's barebone named export instead was the FD-917 root cause; see
|
|
10
|
+
// ../Recaptcha for the full explanation.
|
|
11
|
+
import { ReCAPTCHA } from '../Recaptcha.js';
|
|
9
12
|
import TextField from '../TextField/index.js';
|
|
10
13
|
import Check from '../Icons/Check.js';
|
|
11
14
|
import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
|
|
12
15
|
import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
|
|
13
16
|
import NewsletterSignupQueryNode from '../../__generated__/NewsletterSignupQuery.graphql.js';
|
|
14
17
|
import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
|
|
18
|
+
import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
|
|
15
19
|
const updateCustomerMutation = (_NewsletterSignupUpdateCustomerMutation.hash && _NewsletterSignupUpdateCustomerMutation.hash !== "769087891b6f263122bbb630b3f2ca6c" && console.error("The definition of 'NewsletterSignupUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NewsletterSignupUpdateCustomerMutation);
|
|
16
20
|
export async function loadNewsletterSignupQuery(options) {
|
|
17
21
|
return loadSerializableQuery(NewsletterSignupQueryNode, {}, options);
|
|
@@ -37,10 +41,12 @@ function NewsletterSignupComponent(_ref) {
|
|
|
37
41
|
const [recaptchaToken, setRecaptchaToken] = useState(null);
|
|
38
42
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
|
39
43
|
const recaptchaRef = useRef(null);
|
|
44
|
+
const recaptchaTimeout = useRecaptchaTimeout();
|
|
40
45
|
const environment = useRelayEnvironment();
|
|
41
46
|
const recaptchaEnabled = ((_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.recaptchaEnabled) ?? false;
|
|
42
47
|
const recaptchaSiteKey = (_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.recaptchaSiteKey;
|
|
43
48
|
const submitForm = useCallback(token => {
|
|
49
|
+
recaptchaTimeout.clear();
|
|
44
50
|
setSubmitting(true);
|
|
45
51
|
setPendingSubmit(false);
|
|
46
52
|
setError(null);
|
|
@@ -80,7 +86,7 @@ function NewsletterSignupComponent(_ref) {
|
|
|
80
86
|
setRecaptchaToken(null);
|
|
81
87
|
}
|
|
82
88
|
});
|
|
83
|
-
}, [environment, name, email, newsletterOptIn]);
|
|
89
|
+
}, [environment, name, email, newsletterOptIn, recaptchaTimeout]);
|
|
84
90
|
const handleRecaptchaChange = useCallback(token => {
|
|
85
91
|
setRecaptchaToken(token);
|
|
86
92
|
// If we were waiting for a token to submit, do it now
|
|
@@ -91,15 +97,36 @@ function NewsletterSignupComponent(_ref) {
|
|
|
91
97
|
const handleRecaptchaExpired = useCallback(() => {
|
|
92
98
|
setRecaptchaToken(null);
|
|
93
99
|
}, []);
|
|
100
|
+
|
|
101
|
+
// Recover cleanly when the reCAPTCHA widget can't produce a token, so the
|
|
102
|
+
// button doesn't sit on “Submitting…” forever if the widget fails to load.
|
|
103
|
+
const abortRecaptcha = useCallback(() => {
|
|
104
|
+
var _recaptchaRef$current3;
|
|
105
|
+
recaptchaTimeout.clear();
|
|
106
|
+
setPendingSubmit(false);
|
|
107
|
+
setRecaptchaToken(null);
|
|
108
|
+
(_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
|
|
109
|
+
setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
|
|
110
|
+
}, [recaptchaTimeout]);
|
|
111
|
+
const handleRecaptchaErrored = useCallback(() => {
|
|
112
|
+
if (pendingSubmit) abortRecaptcha();
|
|
113
|
+
}, [pendingSubmit, abortRecaptcha]);
|
|
94
114
|
const handleSubmit = e => {
|
|
95
115
|
e.preventDefault();
|
|
96
116
|
|
|
97
117
|
// If reCAPTCHA is enabled but no token, execute it and wait for callback
|
|
98
118
|
if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
|
|
99
|
-
var _recaptchaRef$
|
|
119
|
+
var _recaptchaRef$current4;
|
|
100
120
|
setPendingSubmit(true);
|
|
101
121
|
setError(null);
|
|
102
|
-
|
|
122
|
+
// Safety net: give up if no token arrives and the reCAPTCHA script never
|
|
123
|
+
// loaded (a challenge in progress keeps `grecaptcha` defined, so we don't
|
|
124
|
+
// cancel on a user who's mid-challenge).
|
|
125
|
+
recaptchaTimeout.start(() => {
|
|
126
|
+
if (isRecaptchaScriptLoaded()) return;
|
|
127
|
+
abortRecaptcha();
|
|
128
|
+
});
|
|
129
|
+
(_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
|
|
103
130
|
return;
|
|
104
131
|
}
|
|
105
132
|
submitForm(recaptchaToken);
|
|
@@ -167,7 +194,8 @@ function NewsletterSignupComponent(_ref) {
|
|
|
167
194
|
sitekey: recaptchaSiteKey,
|
|
168
195
|
size: "invisible",
|
|
169
196
|
onChange: handleRecaptchaChange,
|
|
170
|
-
onExpired: handleRecaptchaExpired
|
|
197
|
+
onExpired: handleRecaptchaExpired,
|
|
198
|
+
onErrored: handleRecaptchaErrored
|
|
171
199
|
}), /*#__PURE__*/React.createElement("div", {
|
|
172
200
|
className: "newsletter-signup__section"
|
|
173
201
|
}, /*#__PURE__*/React.createElement("button", {
|
|
@@ -29,9 +29,13 @@ export default function NodePasswordForm(props) {
|
|
|
29
29
|
// Tell the storefront's unlock route (fontdue-js/next/unlock) about a
|
|
30
30
|
// successful unlock so it can enable Next's draft-mode bypass — without it, a
|
|
31
31
|
// statically-cached font page would keep serving this visitor the password
|
|
32
|
-
// form after they unlocked (the full-route cache is visitor-blind).
|
|
33
|
-
//
|
|
34
|
-
//
|
|
32
|
+
// form after they unlocked (the full-route cache is visitor-blind). The
|
|
33
|
+
// current pathname rides along so the route can scope the bypass cookie to
|
|
34
|
+
// the page being unlocked instead of the whole site — otherwise every page
|
|
35
|
+
// this visitor touches would render dynamically (and slowly) for the rest of
|
|
36
|
+
// their session. Always resolves: the reload must happen even if the endpoint
|
|
37
|
+
// is absent or errors, because on per-request-rendered frameworks the cookie
|
|
38
|
+
// alone unlocks.
|
|
35
39
|
async function notifyUnlockEndpoint(endpoint, token) {
|
|
36
40
|
if (!endpoint || !token) return;
|
|
37
41
|
try {
|
|
@@ -41,7 +45,8 @@ async function notifyUnlockEndpoint(endpoint, token) {
|
|
|
41
45
|
'content-type': 'application/json'
|
|
42
46
|
},
|
|
43
47
|
body: JSON.stringify({
|
|
44
|
-
token
|
|
48
|
+
token,
|
|
49
|
+
path: window.location.pathname
|
|
45
50
|
})
|
|
46
51
|
});
|
|
47
52
|
} catch {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Correct re-export of react-google-recaptcha (FD-917).
|
|
2
|
+
//
|
|
3
|
+
// The package exposes two very different things:
|
|
4
|
+
// - the DEFAULT export is the script-injecting wrapper (built with
|
|
5
|
+
// react-async-script): it loads Google's `api.js`, populates
|
|
6
|
+
// `window.grecaptcha`, then renders the widget. This is what you almost
|
|
7
|
+
// always want.
|
|
8
|
+
// - the NAMED `ReCAPTCHA` export is the *barebone* widget. It never loads the
|
|
9
|
+
// script; it expects you to supply the `grecaptcha` object yourself. On its
|
|
10
|
+
// own it renders an empty <div> and `execute()` is a permanent no-op.
|
|
11
|
+
//
|
|
12
|
+
// Every fontdue-js form imported the named (barebone) export, so on
|
|
13
|
+
// reCAPTCHA-enabled tenants the invisible widget silently never loaded — the
|
|
14
|
+
// checkout button hung on "Submitting…" forever with no token, no request, no
|
|
15
|
+
// error. This module re-exports the wrapper under the same name so callers get
|
|
16
|
+
// the working component.
|
|
17
|
+
//
|
|
18
|
+
// nodenext models this CJS package's default import as the whole module
|
|
19
|
+
// namespace (the @types package declares an ESM `export default` over a CJS
|
|
20
|
+
// runtime), so the default binding isn't directly usable as a JSX component.
|
|
21
|
+
// We cast it to the widget class type. react-async-script forwards refs to the
|
|
22
|
+
// inner widget, so `.execute()` / `.reset()` / `.getValue()` still work through
|
|
23
|
+
// a ref typed as the class.
|
|
24
|
+
import ReCAPTCHAImpl from 'react-google-recaptcha';
|
|
25
|
+
export const ReCAPTCHA = ReCAPTCHAImpl;
|
|
@@ -5,7 +5,10 @@ import _TestFontsForm_Query from "../../__generated__/TestFontsForm_Query.graphq
|
|
|
5
5
|
import _TestFontsFormUpdateCustomerMutation from "../../__generated__/TestFontsFormUpdateCustomerMutation.graphql.js";
|
|
6
6
|
import React, { useState, useRef, useCallback } from 'react';
|
|
7
7
|
import { commitMutation, graphql, useRelayEnvironment, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
|
|
8
|
-
|
|
8
|
+
// The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
|
|
9
|
+
// package's barebone named export instead was the FD-917 root cause; see
|
|
10
|
+
// ../Recaptcha for the full explanation.
|
|
11
|
+
import { ReCAPTCHA } from '../Recaptcha.js';
|
|
9
12
|
import TextField from '../TextField/index.js';
|
|
10
13
|
import { DownloadFonts } from '../Icons/index.js';
|
|
11
14
|
import TestFontsFormQueryNode from '../../__generated__/TestFontsForm_Query.graphql.js';
|
|
@@ -13,6 +16,7 @@ import Checkbox from '../Checkbox/index.js';
|
|
|
13
16
|
import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
|
|
14
17
|
import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
|
|
15
18
|
import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
|
|
19
|
+
import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
|
|
16
20
|
const updateCustomerMutation = (_TestFontsFormUpdateCustomerMutation.hash && _TestFontsFormUpdateCustomerMutation.hash !== "ba56958399f0893bd667ff02c33a6975" && console.error("The definition of 'TestFontsFormUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsFormUpdateCustomerMutation);
|
|
17
21
|
const TestFontsDownloading = _ref => {
|
|
18
22
|
let {
|
|
@@ -46,11 +50,13 @@ const TestFontsFormComponent = _ref2 => {
|
|
|
46
50
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
|
47
51
|
const downloadForm = useRef(null);
|
|
48
52
|
const recaptchaRef = useRef(null);
|
|
53
|
+
const recaptchaTimeout = useRecaptchaTimeout();
|
|
49
54
|
const environment = useRelayEnvironment();
|
|
50
55
|
if (!data.viewer) return null;
|
|
51
56
|
const recaptchaEnabled = ((_data$viewer$settings = data.viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.recaptchaEnabled) ?? false;
|
|
52
57
|
const recaptchaSiteKey = (_data$viewer$settings2 = data.viewer.settings) === null || _data$viewer$settings2 === void 0 ? void 0 : _data$viewer$settings2.recaptchaSiteKey;
|
|
53
58
|
const submitForm = useCallback(token => {
|
|
59
|
+
recaptchaTimeout.clear();
|
|
54
60
|
setSubmitting(true);
|
|
55
61
|
setPendingSubmit(false);
|
|
56
62
|
setError(null);
|
|
@@ -92,7 +98,7 @@ const TestFontsFormComponent = _ref2 => {
|
|
|
92
98
|
setRecaptchaToken(null);
|
|
93
99
|
}
|
|
94
100
|
});
|
|
95
|
-
}, [environment, name, email, newsletterOptIn]);
|
|
101
|
+
}, [environment, name, email, newsletterOptIn, recaptchaTimeout]);
|
|
96
102
|
const handleRecaptchaChange = useCallback(token => {
|
|
97
103
|
setRecaptchaToken(token);
|
|
98
104
|
// If we were waiting for a token to submit, do it now
|
|
@@ -103,6 +109,20 @@ const TestFontsFormComponent = _ref2 => {
|
|
|
103
109
|
const handleRecaptchaExpired = useCallback(() => {
|
|
104
110
|
setRecaptchaToken(null);
|
|
105
111
|
}, []);
|
|
112
|
+
|
|
113
|
+
// Recover cleanly when the reCAPTCHA widget can't produce a token, so the
|
|
114
|
+
// button doesn't sit on “Submitting…” forever if the widget fails to load.
|
|
115
|
+
const abortRecaptcha = useCallback(() => {
|
|
116
|
+
var _recaptchaRef$current3;
|
|
117
|
+
recaptchaTimeout.clear();
|
|
118
|
+
setPendingSubmit(false);
|
|
119
|
+
setRecaptchaToken(null);
|
|
120
|
+
(_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
|
|
121
|
+
setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
|
|
122
|
+
}, [recaptchaTimeout]);
|
|
123
|
+
const handleRecaptchaErrored = useCallback(() => {
|
|
124
|
+
if (pendingSubmit) abortRecaptcha();
|
|
125
|
+
}, [pendingSubmit, abortRecaptcha]);
|
|
106
126
|
const handleSubmit = e => {
|
|
107
127
|
e.preventDefault();
|
|
108
128
|
if (!eulaAgreed) {
|
|
@@ -112,10 +132,17 @@ const TestFontsFormComponent = _ref2 => {
|
|
|
112
132
|
|
|
113
133
|
// If reCAPTCHA is enabled but no token, execute it and wait for callback
|
|
114
134
|
if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
|
|
115
|
-
var _recaptchaRef$
|
|
135
|
+
var _recaptchaRef$current4;
|
|
116
136
|
setPendingSubmit(true);
|
|
117
137
|
setError(null);
|
|
118
|
-
|
|
138
|
+
// Safety net: give up if no token arrives and the reCAPTCHA script never
|
|
139
|
+
// loaded (a challenge in progress keeps `grecaptcha` defined, so we don't
|
|
140
|
+
// cancel on a user who's mid-challenge).
|
|
141
|
+
recaptchaTimeout.start(() => {
|
|
142
|
+
if (isRecaptchaScriptLoaded()) return;
|
|
143
|
+
abortRecaptcha();
|
|
144
|
+
});
|
|
145
|
+
(_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
|
|
119
146
|
return;
|
|
120
147
|
}
|
|
121
148
|
submitForm(recaptchaToken);
|
|
@@ -181,7 +208,8 @@ const TestFontsFormComponent = _ref2 => {
|
|
|
181
208
|
sitekey: recaptchaSiteKey,
|
|
182
209
|
size: "invisible",
|
|
183
210
|
onChange: handleRecaptchaChange,
|
|
184
|
-
onExpired: handleRecaptchaExpired
|
|
211
|
+
onExpired: handleRecaptchaExpired,
|
|
212
|
+
onErrored: handleRecaptchaErrored
|
|
185
213
|
}), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
|
|
186
214
|
className: "submit-button",
|
|
187
215
|
type: "submit",
|
|
@@ -10,6 +10,11 @@ const isDirection = str => {
|
|
|
10
10
|
};
|
|
11
11
|
const parseBool = input => input === 'true' ? true : false;
|
|
12
12
|
|
|
13
|
+
// Split a comma-separated attribute into trimmed, non-empty tokens, so that
|
|
14
|
+
// `axes="wght, opsz"` behaves the same as `axes="wght,opsz"` and a trailing
|
|
15
|
+
// comma doesn't produce a bogus empty entry.
|
|
16
|
+
const splitList = input => (input === null || input === void 0 ? void 0 : input.split(',').map(token => token.trim()).filter(Boolean)) ?? [];
|
|
17
|
+
|
|
13
18
|
// parse a string like "wdth 1000, ital 0.5" into
|
|
14
19
|
// [{ axis: "wdth", value: 1000 }, { axis: "ital", value: 0.5 }]
|
|
15
20
|
function parseVariableSettings(input) {
|
|
@@ -45,12 +50,12 @@ export const TypeTesterStandaloneElement = _ref => {
|
|
|
45
50
|
lineHeight: lineHeight ? parseFloat(lineHeight) : undefined,
|
|
46
51
|
letterSpacing: letterSpacing ? parseFloat(letterSpacing) : undefined,
|
|
47
52
|
fontSize: fontSize ? parseFloat(fontSize) : undefined,
|
|
48
|
-
axes: (axes
|
|
49
|
-
features: (features
|
|
50
|
-
featureSettings: featuresSelected
|
|
53
|
+
axes: splitList(axes),
|
|
54
|
+
features: splitList(features),
|
|
55
|
+
featureSettings: featuresSelected ? splitList(featuresSelected).map(feature => ({
|
|
51
56
|
feature,
|
|
52
57
|
value: '1'
|
|
53
|
-
})),
|
|
58
|
+
})) : undefined,
|
|
54
59
|
alignment: isAlignment(alignment) ? alignment : undefined,
|
|
55
60
|
direction: isDirection(direction) ? direction : undefined,
|
|
56
61
|
variableSettings: parseVariableSettings(variableSettings)
|
|
@@ -17,6 +17,22 @@ const TypeTesterVariableAxes = _ref => {
|
|
|
17
17
|
id
|
|
18
18
|
});
|
|
19
19
|
const fontStyle = useFragment((_TypeTesterVariableAxes_fontStyle.hash && _TypeTesterVariableAxes_fontStyle.hash !== "a73e45dbb7a154107456088125a01f88" && console.error("The definition of 'TypeTesterVariableAxes_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesterVariableAxes_fontStyle), fontStyleKey);
|
|
20
|
+
|
|
21
|
+
// Warn when axes are requested that the font style doesn't actually expose —
|
|
22
|
+
// a common silent misconfiguration. The `axes` attribute only whitelists which
|
|
23
|
+
// of the font's variable axes to show; it can't surface axes the font file
|
|
24
|
+
// doesn't contain (e.g. pointing a tester at a static style, or a wrong/mis-cased
|
|
25
|
+
// tag). Without this hint the sliders just fail to appear with no explanation.
|
|
26
|
+
const requestedAxesKey = (axes ?? []).filter(Boolean).join(',');
|
|
27
|
+
const availableAxesKey = (fontStyle.variableAxes ?? []).map(variableAxis => variableAxis === null || variableAxis === void 0 ? void 0 : variableAxis.axis).filter(notEmpty).join(',');
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!requestedAxesKey) return;
|
|
30
|
+
const available = new Set(availableAxesKey ? availableAxesKey.split(',') : []);
|
|
31
|
+
const missing = requestedAxesKey.split(',').filter(axis => !available.has(axis));
|
|
32
|
+
if (missing.length === 0) return;
|
|
33
|
+
const label = missing.length === 1 ? 'axis' : 'axes';
|
|
34
|
+
console.warn(`fontdue-type-tester: variable ${label} ${missing.map(axis => `"${axis}"`).join(', ')} ` + `requested via the "axes" attribute, but ` + (available.size === 0 ? `this font style exposes no variable axes (is it a variable font?). No axis sliders will be shown.` : `this font style only exposes ${[...available].map(axis => `"${axis}"`).join(', ')}. ` + `No slider will be shown for the missing ${label}.`));
|
|
35
|
+
}, [requestedAxesKey, availableAxesKey]);
|
|
20
36
|
if (!variableSettings) return null;
|
|
21
37
|
if (!axes) return null;
|
|
22
38
|
const handleChange = (axis, value) => {
|
|
@@ -63,7 +63,10 @@ import _StoreModalUnifiedCheckout_viewer from "../../__generated__/StoreModalUni
|
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
65
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
66
|
-
|
|
66
|
+
// The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
|
|
67
|
+
// package's barebone named export instead was the FD-917 root cause; see
|
|
68
|
+
// ../Recaptcha for the full explanation.
|
|
69
|
+
import { ReCAPTCHA } from '../Recaptcha.js';
|
|
67
70
|
import * as Sentry from '@sentry/react';
|
|
68
71
|
import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
|
|
69
72
|
import AddressFields from '../Cart/AddressFields.js';
|
|
@@ -76,6 +79,7 @@ import { Check, X } from '../Icons/index.js';
|
|
|
76
79
|
import ConfigContext from '../ConfigContext.js';
|
|
77
80
|
import { Price } from '../Price/index.js';
|
|
78
81
|
import { sendOrderTracking } from '../Cart/orderTracking.js';
|
|
82
|
+
import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
|
|
79
83
|
|
|
80
84
|
/* const LICENSEE_REQUIRED_FIELDS = ['organization', 'country'] as (keyof Identity)[]; */
|
|
81
85
|
|
|
@@ -297,7 +301,11 @@ export default function StoreModalUnifiedCheckout(_ref2) {
|
|
|
297
301
|
// reCAPTCHA state
|
|
298
302
|
const [recaptchaToken, setRecaptchaToken] = useState(null);
|
|
299
303
|
const [pendingCustomerSubmit, setPendingCustomerSubmit] = useState(null);
|
|
304
|
+
// Error shown inside the contact-information section (separate from the
|
|
305
|
+
// payment-section `error`, which isn't rendered until a customer exists).
|
|
306
|
+
const [customerError, setCustomerError] = useState(null);
|
|
300
307
|
const recaptchaRef = useRef(null);
|
|
308
|
+
const recaptchaTimeout = useRecaptchaTimeout();
|
|
301
309
|
const recaptchaEnabled = ((_viewer$settings = viewer.settings) === null || _viewer$settings === void 0 ? void 0 : _viewer$settings.recaptchaEnabled) ?? false;
|
|
302
310
|
const recaptchaSiteKey = (_viewer$settings2 = viewer.settings) === null || _viewer$settings2 === void 0 ? void 0 : _viewer$settings2.recaptchaSiteKey;
|
|
303
311
|
const onCompleted = (res, errors, onSuccess, onError) => {
|
|
@@ -417,7 +425,9 @@ export default function StoreModalUnifiedCheckout(_ref2) {
|
|
|
417
425
|
|
|
418
426
|
// Submit customer data with optional reCAPTCHA token
|
|
419
427
|
const submitCustomer = useCallback((token, callbacks) => {
|
|
428
|
+
recaptchaTimeout.clear();
|
|
420
429
|
setPendingCustomerSubmit(null);
|
|
430
|
+
setCustomerError(null);
|
|
421
431
|
if (!billingIdentity.name && !billingIdentity.email) {
|
|
422
432
|
setBillingIdentity({
|
|
423
433
|
...billingIdentity,
|
|
@@ -444,7 +454,7 @@ export default function StoreModalUnifiedCheckout(_ref2) {
|
|
|
444
454
|
(_recaptchaRef$current2 = recaptchaRef.current) === null || _recaptchaRef$current2 === void 0 ? void 0 : _recaptchaRef$current2.reset();
|
|
445
455
|
setRecaptchaToken(null);
|
|
446
456
|
});
|
|
447
|
-
}, [customer, billingIdentity, updateCustomer]);
|
|
457
|
+
}, [customer, billingIdentity, updateCustomer, recaptchaTimeout]);
|
|
448
458
|
const handleRecaptchaChange = useCallback(token => {
|
|
449
459
|
setRecaptchaToken(token);
|
|
450
460
|
// If we were waiting for a token to submit, do it now
|
|
@@ -455,14 +465,43 @@ export default function StoreModalUnifiedCheckout(_ref2) {
|
|
|
455
465
|
const handleRecaptchaExpired = useCallback(() => {
|
|
456
466
|
setRecaptchaToken(null);
|
|
457
467
|
}, []);
|
|
468
|
+
|
|
469
|
+
// Give up cleanly when the reCAPTCHA widget can't produce a token. Without
|
|
470
|
+
// this, a submit that's waiting on `execute()` would sit on “Submitting…”
|
|
471
|
+
// forever (the widget failing to load is a silent no-op). Reset the section
|
|
472
|
+
// and show an error so the buyer can retry.
|
|
473
|
+
const abortCustomerRecaptcha = useCallback(callbacks => {
|
|
474
|
+
var _recaptchaRef$current3;
|
|
475
|
+
recaptchaTimeout.clear();
|
|
476
|
+
setPendingCustomerSubmit(null);
|
|
477
|
+
setRecaptchaToken(null);
|
|
478
|
+
(_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
|
|
479
|
+
setCustomerError(RECAPTCHA_UNAVAILABLE_MESSAGE);
|
|
480
|
+
callbacks.onError();
|
|
481
|
+
}, [recaptchaTimeout]);
|
|
482
|
+
const handleRecaptchaErrored = useCallback(() => {
|
|
483
|
+
// Widget loaded but errored during execution — recover the same way.
|
|
484
|
+
if (pendingCustomerSubmit) {
|
|
485
|
+
abortCustomerRecaptcha(pendingCustomerSubmit.callbacks);
|
|
486
|
+
}
|
|
487
|
+
}, [pendingCustomerSubmit, abortCustomerRecaptcha]);
|
|
458
488
|
const handleCustomerSubmit = callbacks => {
|
|
459
489
|
// If reCAPTCHA is enabled but no token, execute it and wait for callback
|
|
460
490
|
if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
|
|
461
|
-
var _recaptchaRef$
|
|
491
|
+
var _recaptchaRef$current4;
|
|
492
|
+
setCustomerError(null);
|
|
462
493
|
setPendingCustomerSubmit({
|
|
463
494
|
callbacks
|
|
464
495
|
});
|
|
465
|
-
|
|
496
|
+
// Safety net: if no token arrives (e.g. Google's api.js never loaded),
|
|
497
|
+
// don't hang. When the timer fires we only give up if the reCAPTCHA
|
|
498
|
+
// script never loaded — if it did, the buyer may be mid-challenge, so we
|
|
499
|
+
// keep waiting for their `onChange` rather than cancelling on them.
|
|
500
|
+
recaptchaTimeout.start(() => {
|
|
501
|
+
if (isRecaptchaScriptLoaded()) return;
|
|
502
|
+
abortCustomerRecaptcha(callbacks);
|
|
503
|
+
});
|
|
504
|
+
(_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
|
|
466
505
|
return;
|
|
467
506
|
}
|
|
468
507
|
submitCustomer(recaptchaToken, callbacks);
|
|
@@ -661,12 +700,15 @@ export default function StoreModalUnifiedCheckout(_ref2) {
|
|
|
661
700
|
onChange: handleCustomerChange,
|
|
662
701
|
optInLabel: (_viewer$settings3 = viewer.settings) === null || _viewer$settings3 === void 0 ? void 0 : _viewer$settings3.newsletterOptInLabel,
|
|
663
702
|
errors: customerErrorsObject
|
|
664
|
-
}),
|
|
703
|
+
}), customerError && /*#__PURE__*/React.createElement("div", {
|
|
704
|
+
className: "store-modal__cart__error"
|
|
705
|
+
}, customerError), recaptchaEnabled && recaptchaSiteKey && /*#__PURE__*/React.createElement(ReCAPTCHA, {
|
|
665
706
|
ref: recaptchaRef,
|
|
666
707
|
sitekey: recaptchaSiteKey,
|
|
667
708
|
size: "invisible",
|
|
668
709
|
onChange: handleRecaptchaChange,
|
|
669
|
-
onExpired: handleRecaptchaExpired
|
|
710
|
+
onExpired: handleRecaptchaExpired,
|
|
711
|
+
onErrored: handleRecaptchaErrored
|
|
670
712
|
}))
|
|
671
713
|
}), order.customer && !hasCountryMismatch ? /*#__PURE__*/React.createElement(StoreModalLicenseeIsBillingIdentityElement, {
|
|
672
714
|
disabled: false,
|
package/dist/fontdue.css
CHANGED
|
@@ -3480,16 +3480,24 @@ textarea.text-field__input {
|
|
|
3480
3480
|
|
|
3481
3481
|
.fontdue-admin-toolbar {
|
|
3482
3482
|
position: fixed;
|
|
3483
|
-
|
|
3484
|
-
bottom:
|
|
3483
|
+
left: 50%;
|
|
3484
|
+
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
|
3485
|
+
transform: translateX(-50%);
|
|
3485
3486
|
z-index: 2147483000;
|
|
3486
|
-
|
|
3487
|
+
display: flex;
|
|
3488
|
+
flex-direction: column;
|
|
3489
|
+
align-items: center;
|
|
3490
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
3487
3491
|
font-size: 13px;
|
|
3488
3492
|
line-height: 1.45;
|
|
3493
|
+
text-align: left;
|
|
3489
3494
|
color: #fff;
|
|
3490
|
-
--fdat-surface: #
|
|
3495
|
+
--fdat-surface: #101014;
|
|
3491
3496
|
--fdat-border: rgba(255, 255, 255, 0.16);
|
|
3492
3497
|
--fdat-muted: rgba(255, 255, 255, 0.5);
|
|
3498
|
+
--fdat-accent: #5b5bd6;
|
|
3499
|
+
--fdat-well: rgba(255, 255, 255, 0.05);
|
|
3500
|
+
--fdat-well-on: rgba(91, 91, 214, 0.18);
|
|
3493
3501
|
}
|
|
3494
3502
|
.fontdue-admin-toolbar * {
|
|
3495
3503
|
box-sizing: border-box;
|
|
@@ -3518,43 +3526,79 @@ textarea.text-field__input {
|
|
|
3518
3526
|
display: inline-flex;
|
|
3519
3527
|
align-items: center;
|
|
3520
3528
|
gap: 8px;
|
|
3521
|
-
padding: 8px
|
|
3529
|
+
padding: 8px 16px;
|
|
3522
3530
|
font-size: 13px;
|
|
3523
|
-
background:
|
|
3531
|
+
background: #0a0a0a;
|
|
3524
3532
|
color: #fff;
|
|
3525
3533
|
border: 1px solid var(--fdat-border);
|
|
3534
|
+
border-radius: 999px;
|
|
3535
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
|
3536
|
+
white-space: nowrap;
|
|
3526
3537
|
}
|
|
3527
3538
|
.fontdue-admin-toolbar__button:hover {
|
|
3528
3539
|
border-color: rgba(255, 255, 255, 0.4);
|
|
3529
3540
|
}
|
|
3530
3541
|
.fontdue-admin-toolbar__button:focus-visible {
|
|
3531
3542
|
outline: 1px solid #fff;
|
|
3532
|
-
outline-offset:
|
|
3543
|
+
outline-offset: 2px;
|
|
3544
|
+
}
|
|
3545
|
+
.fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button {
|
|
3546
|
+
background: var(--fdat-accent);
|
|
3547
|
+
border-color: var(--fdat-accent);
|
|
3548
|
+
}
|
|
3549
|
+
.fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button:hover {
|
|
3550
|
+
border-color: #fff;
|
|
3533
3551
|
}
|
|
3534
3552
|
|
|
3535
|
-
.fontdue-admin-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3553
|
+
.fontdue-admin-toolbar__mark {
|
|
3554
|
+
flex: none;
|
|
3555
|
+
width: 16px;
|
|
3556
|
+
height: 16px;
|
|
3557
|
+
display: grid;
|
|
3558
|
+
place-items: center;
|
|
3559
|
+
background: var(--fdat-accent);
|
|
3560
|
+
color: #fff;
|
|
3561
|
+
border-radius: 5px;
|
|
3562
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
3563
|
+
font-size: 10px;
|
|
3564
|
+
line-height: 1;
|
|
3539
3565
|
}
|
|
3540
|
-
.fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-
|
|
3566
|
+
.fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__button .fontdue-admin-toolbar__mark {
|
|
3541
3567
|
background: #fff;
|
|
3568
|
+
color: var(--fdat-accent);
|
|
3542
3569
|
}
|
|
3543
3570
|
|
|
3544
3571
|
.fontdue-admin-toolbar__panel {
|
|
3545
|
-
width:
|
|
3546
|
-
|
|
3547
|
-
|
|
3572
|
+
width: 280px;
|
|
3573
|
+
max-width: calc(100vw - 24px);
|
|
3574
|
+
margin-bottom: 10px;
|
|
3575
|
+
padding: 16px;
|
|
3548
3576
|
background: var(--fdat-surface);
|
|
3549
3577
|
border: 1px solid var(--fdat-border);
|
|
3578
|
+
border-radius: 14px;
|
|
3579
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
|
|
3550
3580
|
}
|
|
3551
3581
|
|
|
3552
3582
|
.fontdue-admin-toolbar__header {
|
|
3553
3583
|
display: flex;
|
|
3554
3584
|
justify-content: space-between;
|
|
3555
|
-
align-items:
|
|
3585
|
+
align-items: center;
|
|
3556
3586
|
gap: 10px;
|
|
3557
|
-
margin-bottom:
|
|
3587
|
+
margin-bottom: 14px;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
.fontdue-admin-toolbar__brand {
|
|
3591
|
+
display: inline-flex;
|
|
3592
|
+
align-items: center;
|
|
3593
|
+
gap: 8px;
|
|
3594
|
+
font-size: 12.5px;
|
|
3595
|
+
font-weight: 700;
|
|
3596
|
+
}
|
|
3597
|
+
.fontdue-admin-toolbar__brand .fontdue-admin-toolbar__mark {
|
|
3598
|
+
width: 19px;
|
|
3599
|
+
height: 19px;
|
|
3600
|
+
border-radius: 6px;
|
|
3601
|
+
font-size: 11.5px;
|
|
3558
3602
|
}
|
|
3559
3603
|
|
|
3560
3604
|
.fontdue-admin-toolbar__title {
|
|
@@ -3569,16 +3613,93 @@ textarea.text-field__input {
|
|
|
3569
3613
|
}
|
|
3570
3614
|
|
|
3571
3615
|
.fontdue-admin-toolbar__toggle {
|
|
3616
|
+
position: relative;
|
|
3572
3617
|
display: flex;
|
|
3618
|
+
justify-content: space-between;
|
|
3573
3619
|
align-items: center;
|
|
3574
|
-
gap:
|
|
3620
|
+
gap: 12px;
|
|
3621
|
+
padding: 11px 12px;
|
|
3622
|
+
background: var(--fdat-well);
|
|
3623
|
+
border-radius: 10px;
|
|
3575
3624
|
cursor: pointer;
|
|
3576
3625
|
}
|
|
3577
3626
|
.fontdue-admin-toolbar__toggle input {
|
|
3627
|
+
position: absolute;
|
|
3628
|
+
inset: 0;
|
|
3629
|
+
width: 100%;
|
|
3630
|
+
height: 100%;
|
|
3578
3631
|
margin: 0;
|
|
3579
|
-
|
|
3632
|
+
opacity: 0;
|
|
3580
3633
|
cursor: pointer;
|
|
3581
3634
|
}
|
|
3635
|
+
.fontdue-admin-toolbar__toggle input:disabled {
|
|
3636
|
+
cursor: default;
|
|
3637
|
+
}
|
|
3638
|
+
.fontdue-admin-toolbar[data-previewing=true] .fontdue-admin-toolbar__toggle {
|
|
3639
|
+
background: var(--fdat-well-on);
|
|
3640
|
+
}
|
|
3641
|
+
.fontdue-admin-toolbar__toggle:has(input:focus-visible) {
|
|
3642
|
+
outline: 1px solid #fff;
|
|
3643
|
+
outline-offset: 2px;
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
.fontdue-admin-toolbar__toggle-text {
|
|
3647
|
+
display: flex;
|
|
3648
|
+
flex-direction: column;
|
|
3649
|
+
gap: 2px;
|
|
3650
|
+
}
|
|
3651
|
+
.fontdue-admin-toolbar__toggle-text strong {
|
|
3652
|
+
font-size: 12.5px;
|
|
3653
|
+
font-weight: 700;
|
|
3654
|
+
}
|
|
3655
|
+
.fontdue-admin-toolbar__toggle-text span {
|
|
3656
|
+
font-size: 10.5px;
|
|
3657
|
+
color: var(--fdat-muted);
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
.fontdue-admin-toolbar__switch {
|
|
3661
|
+
flex: none;
|
|
3662
|
+
width: 36px;
|
|
3663
|
+
height: 22px;
|
|
3664
|
+
border-radius: 999px;
|
|
3665
|
+
background: rgba(255, 255, 255, 0.18);
|
|
3666
|
+
position: relative;
|
|
3667
|
+
transition: background 0.15s;
|
|
3668
|
+
}
|
|
3669
|
+
.fontdue-admin-toolbar__switch::after {
|
|
3670
|
+
content: "";
|
|
3671
|
+
position: absolute;
|
|
3672
|
+
top: 3px;
|
|
3673
|
+
left: 3px;
|
|
3674
|
+
width: 16px;
|
|
3675
|
+
height: 16px;
|
|
3676
|
+
border-radius: 50%;
|
|
3677
|
+
background: #fff;
|
|
3678
|
+
transition: left 0.15s;
|
|
3679
|
+
}
|
|
3680
|
+
input:checked + .fontdue-admin-toolbar__switch {
|
|
3681
|
+
background: var(--fdat-accent);
|
|
3682
|
+
}
|
|
3683
|
+
input:checked + .fontdue-admin-toolbar__switch::after {
|
|
3684
|
+
left: 17px;
|
|
3685
|
+
}
|
|
3686
|
+
input:disabled + .fontdue-admin-toolbar__switch {
|
|
3687
|
+
opacity: 0.55;
|
|
3688
|
+
}
|
|
3689
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3690
|
+
.fontdue-admin-toolbar__switch {
|
|
3691
|
+
transition: none;
|
|
3692
|
+
}
|
|
3693
|
+
.fontdue-admin-toolbar__switch::after {
|
|
3694
|
+
transition: none;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
.fontdue-admin-toolbar__status {
|
|
3699
|
+
margin: 9px 0 0;
|
|
3700
|
+
font-size: 11px;
|
|
3701
|
+
color: #b3b3f2;
|
|
3702
|
+
}
|
|
3582
3703
|
|
|
3583
3704
|
.fontdue-admin-toolbar__hint {
|
|
3584
3705
|
margin: 10px 0 0;
|
|
@@ -3590,6 +3711,9 @@ textarea.text-field__input {
|
|
|
3590
3711
|
display: flex;
|
|
3591
3712
|
flex-direction: column;
|
|
3592
3713
|
gap: 10px;
|
|
3714
|
+
padding: 11px 12px;
|
|
3715
|
+
background: rgba(224, 162, 60, 0.12);
|
|
3716
|
+
border-radius: 10px;
|
|
3593
3717
|
}
|
|
3594
3718
|
|
|
3595
3719
|
.fontdue-admin-toolbar__expired-text {
|
|
@@ -3597,8 +3721,8 @@ textarea.text-field__input {
|
|
|
3597
3721
|
align-items: flex-start;
|
|
3598
3722
|
gap: 8px;
|
|
3599
3723
|
margin: 0;
|
|
3600
|
-
font-size:
|
|
3601
|
-
color:
|
|
3724
|
+
font-size: 11.5px;
|
|
3725
|
+
color: #e8c07a;
|
|
3602
3726
|
}
|
|
3603
3727
|
|
|
3604
3728
|
.fontdue-admin-toolbar__warning-icon {
|
|
@@ -3660,7 +3784,7 @@ textarea.text-field__input {
|
|
|
3660
3784
|
gap: 8px;
|
|
3661
3785
|
width: 100%;
|
|
3662
3786
|
margin: 0;
|
|
3663
|
-
padding:
|
|
3787
|
+
padding: 8px 11px;
|
|
3664
3788
|
font: inherit;
|
|
3665
3789
|
font-size: 12px;
|
|
3666
3790
|
text-align: left;
|
|
@@ -3668,7 +3792,7 @@ textarea.text-field__input {
|
|
|
3668
3792
|
color: #fff;
|
|
3669
3793
|
background: transparent;
|
|
3670
3794
|
border: 1px solid var(--fdat-border);
|
|
3671
|
-
border-radius:
|
|
3795
|
+
border-radius: 10px;
|
|
3672
3796
|
cursor: pointer;
|
|
3673
3797
|
}
|
|
3674
3798
|
.fontdue-admin-toolbar__action:hover {
|
|
@@ -3694,7 +3818,7 @@ textarea.text-field__input {
|
|
|
3694
3818
|
padding-top: 10px;
|
|
3695
3819
|
border-top: 1px solid var(--fdat-border);
|
|
3696
3820
|
color: var(--fdat-muted);
|
|
3697
|
-
font-size:
|
|
3821
|
+
font-size: 10.5px;
|
|
3698
3822
|
word-break: break-word;
|
|
3699
3823
|
}
|
|
3700
3824
|
|
|
@@ -3702,7 +3826,7 @@ textarea.text-field__input {
|
|
|
3702
3826
|
display: block;
|
|
3703
3827
|
margin-top: 4px;
|
|
3704
3828
|
color: rgba(255, 255, 255, 0.32);
|
|
3705
|
-
font-size:
|
|
3829
|
+
font-size: 10.5px;
|
|
3706
3830
|
}
|
|
3707
3831
|
|
|
3708
3832
|
:root {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const RECAPTCHA_TIMEOUT_MS = 15000;
|
|
2
|
+
export declare const RECAPTCHA_UNAVAILABLE_MESSAGE = "We couldn\u2019t verify that you\u2019re human. Please refresh the page and try again.";
|
|
3
|
+
export declare function isRecaptchaScriptLoaded(): boolean;
|
|
4
|
+
export interface RecaptchaTimeout {
|
|
5
|
+
/** (Re)start the timer. Any previously scheduled callback is cancelled. */
|
|
6
|
+
start(onTimeout: () => void): void;
|
|
7
|
+
/** Cancel a pending timer (e.g. once a token arrives). Safe to call twice. */
|
|
8
|
+
clear(): void;
|
|
9
|
+
}
|
|
10
|
+
export declare function createRecaptchaTimeout(timeoutMs?: number): RecaptchaTimeout;
|
|
11
|
+
export declare function useRecaptchaTimeout(timeoutMs?: number): RecaptchaTimeout;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// How long we wait for the invisible reCAPTCHA widget to hand back a token
|
|
4
|
+
// after calling `execute()` before we give up. reCAPTCHA normally resolves in
|
|
5
|
+
// a second or two; a genuine challenge is handled by the readiness probe below
|
|
6
|
+
// (we don't cut a buyer off mid-challenge), so this only needs to be long
|
|
7
|
+
// enough to rule out a transient slow load.
|
|
8
|
+
export const RECAPTCHA_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
// User-facing message shown when the reCAPTCHA widget never produced a token
|
|
11
|
+
// (typically because Google's api.js failed to load). Curly apostrophes per
|
|
12
|
+
// the Fontdue house style.
|
|
13
|
+
export const RECAPTCHA_UNAVAILABLE_MESSAGE = 'We couldn’t verify that you’re human. Please refresh the page and try again.';
|
|
14
|
+
|
|
15
|
+
// True once Google's reCAPTCHA script (`window.grecaptcha`) has loaded. We use
|
|
16
|
+
// this to tell the two failure modes apart when the timeout fires:
|
|
17
|
+
// - script never loaded → the widget is dead and would hang forever; reset.
|
|
18
|
+
// - script loaded, no token yet → the buyer may be mid-challenge; keep
|
|
19
|
+
// waiting rather than cancelling on them.
|
|
20
|
+
export function isRecaptchaScriptLoaded() {
|
|
21
|
+
// Google's api.js defines `grecaptcha` on the global object; in a browser
|
|
22
|
+
// `globalThis === window`, so this reads the same property while staying
|
|
23
|
+
// safe under SSR and unit tests where `window` may be undefined.
|
|
24
|
+
return typeof globalThis !== 'undefined' && globalThis.grecaptcha != null;
|
|
25
|
+
}
|
|
26
|
+
// Framework-free timer wrapper. Kept as a plain factory (not a hook) so the
|
|
27
|
+
// resilience contract can be unit-tested with fake timers without a DOM.
|
|
28
|
+
export function createRecaptchaTimeout() {
|
|
29
|
+
let timeoutMs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : RECAPTCHA_TIMEOUT_MS;
|
|
30
|
+
let handle = null;
|
|
31
|
+
const clear = () => {
|
|
32
|
+
if (handle !== null) {
|
|
33
|
+
clearTimeout(handle);
|
|
34
|
+
handle = null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const start = onTimeout => {
|
|
38
|
+
clear();
|
|
39
|
+
handle = setTimeout(() => {
|
|
40
|
+
handle = null;
|
|
41
|
+
onTimeout();
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
start,
|
|
46
|
+
clear
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// React hook wrapper: one stable timer per component that auto-clears on
|
|
51
|
+
// unmount (so a pending timeout never fires setState after teardown).
|
|
52
|
+
export function useRecaptchaTimeout(timeoutMs) {
|
|
53
|
+
const ref = useRef(null);
|
|
54
|
+
if (ref.current === null) {
|
|
55
|
+
ref.current = createRecaptchaTimeout(timeoutMs);
|
|
56
|
+
}
|
|
57
|
+
const timeout = ref.current;
|
|
58
|
+
useEffect(() => () => timeout.clear(), [timeout]);
|
|
59
|
+
return timeout;
|
|
60
|
+
}
|
package/dist/next/unlock.js
CHANGED
|
@@ -22,16 +22,44 @@
|
|
|
22
22
|
// forged POST reveals nothing and buys the caller only their own dynamic
|
|
23
23
|
// renders, exactly like a forged preview POST.
|
|
24
24
|
//
|
|
25
|
+
// The bypass is scoped to the unlocked page's path, not the whole site.
|
|
26
|
+
// draftMode().enable() sets the cookie with Path=/, which would take EVERY
|
|
27
|
+
// page this visitor touches off the full-route cache — and because a render
|
|
28
|
+
// carrying a node-access token also opts all of its GraphQL fetches out of the
|
|
29
|
+
// shared data cache, one unlock made the visitor's whole browsing session
|
|
30
|
+
// dynamically rendered and measurably slow (seconds per page on heavy pages).
|
|
31
|
+
// Draft mode is decided per-request by whether the browser SENDS the bypass
|
|
32
|
+
// cookie, and the browser decides that by cookie Path — so the form posts the
|
|
33
|
+
// pathname it unlocked on and we narrow the cookie to it. Public pages stay on
|
|
34
|
+
// the static cache; only the unlocked collection's pages render dynamically
|
|
35
|
+
// for this visitor. Unlocking several collections yields several same-name
|
|
36
|
+
// cookies with different paths, which browsers store and send independently.
|
|
37
|
+
// (Admin preview keeps its site-wide Path=/ bypass — previewing the whole site
|
|
38
|
+
// dynamically is the point there.)
|
|
39
|
+
//
|
|
25
40
|
// The bypass cookie is a session cookie, while the node-access cookie lasts ~30
|
|
26
41
|
// days. A returning visitor whose bypass lapsed sees the static form again and
|
|
27
42
|
// re-enters the password; re-unlocking re-enables the bypass. There is no
|
|
28
43
|
// DELETE: draft mode is shared with admin preview, so an unlock exit must not
|
|
29
44
|
// tear down an active preview — the bypass simply expires with the session.
|
|
30
45
|
|
|
31
|
-
import { draftMode } from 'next/headers';
|
|
46
|
+
import { cookies, draftMode } from 'next/headers';
|
|
32
47
|
|
|
33
48
|
/** Default path the templates mount the handler at. */
|
|
34
49
|
export const UNLOCK_ENDPOINT = '/api/unlock';
|
|
50
|
+
|
|
51
|
+
// Next's draft-mode bypass cookie. The name is Next's stable, documented
|
|
52
|
+
// public contract (the same one draftMode().enable() sets), but it isn't
|
|
53
|
+
// exported from a public entry point, so it's spelled out here.
|
|
54
|
+
const PRERENDER_BYPASS_COOKIE = '__prerender_bypass';
|
|
55
|
+
|
|
56
|
+
// A client-sent pathname we are willing to put in a Set-Cookie Path attribute:
|
|
57
|
+
// absolute, bounded, visible ASCII with no ';' — i.e. what
|
|
58
|
+
// window.location.pathname (always percent-encoded) actually produces, and
|
|
59
|
+
// nothing that could terminate or escape the attribute (Next serializes the
|
|
60
|
+
// path verbatim). Anything else falls back to the site-wide default.
|
|
61
|
+
const SCOPE_PATH_MAX_LENGTH = 512;
|
|
62
|
+
const SCOPE_PATH_RE = /^\/[\x21-\x3a\x3c-\x7e]*$/;
|
|
35
63
|
export async function POST(request) {
|
|
36
64
|
const body = await request.json().catch(() => ({}));
|
|
37
65
|
if (typeof body.token !== 'string' || body.token === '') {
|
|
@@ -43,7 +71,42 @@ export async function POST(request) {
|
|
|
43
71
|
});
|
|
44
72
|
}
|
|
45
73
|
(await draftMode()).enable();
|
|
74
|
+
await scopeBypassCookie(scopePath(body.path));
|
|
46
75
|
return Response.json({
|
|
47
76
|
ok: true
|
|
48
77
|
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The cookie Path to narrow the bypass to, or undefined to leave it site-wide
|
|
81
|
+
// (path missing — an older form — or unusable). Trailing slashes are stripped
|
|
82
|
+
// because cookie path-matching treats "/fonts/x" as covering both the page
|
|
83
|
+
// itself and everything under it, while "/fonts/x/" would miss the page.
|
|
84
|
+
function scopePath(path) {
|
|
85
|
+
if (typeof path !== 'string' || path.length > SCOPE_PATH_MAX_LENGTH || !SCOPE_PATH_RE.test(path)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
89
|
+
return trimmed === '' ? undefined : trimmed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Replace the pending site-wide bypass cookie enable() just queued with one
|
|
93
|
+
// scoped to `path`. cookies() in a route handler exposes the same mutable
|
|
94
|
+
// store enable() wrote to, and setting the same cookie name again replaces the
|
|
95
|
+
// queued Set-Cookie rather than adding a second one — so exactly one bypass
|
|
96
|
+
// cookie leaves this response, and no Path=/ variant that could clobber an
|
|
97
|
+
// admin's site-wide preview bypass. The explicit attributes mirror enable()'s
|
|
98
|
+
// as a fallback; the spread keeps whatever enable() actually set (bar the
|
|
99
|
+
// path) if Next ever changes them.
|
|
100
|
+
async function scopeBypassCookie(path) {
|
|
101
|
+
if (!path) return;
|
|
102
|
+
const jar = await cookies();
|
|
103
|
+
const bypass = jar.get(PRERENDER_BYPASS_COOKIE);
|
|
104
|
+
if (!(bypass !== null && bypass !== void 0 && bypass.value)) return;
|
|
105
|
+
jar.set({
|
|
106
|
+
httpOnly: true,
|
|
107
|
+
sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
|
|
108
|
+
secure: process.env.NODE_ENV !== 'development',
|
|
109
|
+
...bypass,
|
|
110
|
+
path
|
|
111
|
+
});
|
|
49
112
|
}
|
|
@@ -8,7 +8,7 @@ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
|
|
|
8
8
|
// (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
|
|
9
9
|
// Exported so UI (the admin toolbar) can surface it without re-reading the
|
|
10
10
|
// build-time global in a 'use client' module.
|
|
11
|
-
export const version = "3.2.
|
|
11
|
+
export const version = "3.2.1";
|
|
12
12
|
const IS_SERVER = typeof window === typeof undefined;
|
|
13
13
|
|
|
14
14
|
// Opt server fetches into Next's data cache only in production; dev stays
|
package/package.json
CHANGED
package/types/next-headers.d.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
// Minimal declaration so src/next/tenant.ts type-checks without `next`
|
|
2
2
|
// installed (it's the host app's dependency; this package only ever runs the
|
|
3
3
|
// import inside a Next.js server).
|
|
4
|
+
//
|
|
5
|
+
// The cookie shape mirrors Next's route-handler cookie store: get() also
|
|
6
|
+
// carries the queued cookie's attributes (the store is ResponseCookies-backed,
|
|
7
|
+
// so a cookie draftMode().enable() just set comes back with its path/flags),
|
|
8
|
+
// and set() with an existing name replaces the queued Set-Cookie. The unlock
|
|
9
|
+
// route (src/next/unlock.ts) relies on both to narrow the bypass cookie's
|
|
10
|
+
// path.
|
|
4
11
|
declare module 'next/headers' {
|
|
12
|
+
interface CookieAttributes {
|
|
13
|
+
path?: string;
|
|
14
|
+
httpOnly?: boolean;
|
|
15
|
+
secure?: boolean;
|
|
16
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
17
|
+
expires?: Date | number;
|
|
18
|
+
maxAge?: number;
|
|
19
|
+
domain?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
export function draftMode(): Promise<{
|
|
6
23
|
isEnabled: boolean;
|
|
7
24
|
enable(): void;
|
|
8
25
|
disable(): void;
|
|
9
26
|
}>;
|
|
10
27
|
export function cookies(): Promise<{
|
|
11
|
-
get(
|
|
28
|
+
get(
|
|
29
|
+
name: string,
|
|
30
|
+
): ({ name: string; value: string } & CookieAttributes) | undefined;
|
|
31
|
+
set(cookie: { name: string; value: string } & CookieAttributes): void;
|
|
12
32
|
}>;
|
|
13
33
|
}
|