fontdue-js 3.1.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 +14 -0
- package/dist/__tests__/nextAdapter.test.js +178 -4
- 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.d.ts +11 -0
- package/dist/components/NodePasswordForm/index.js +29 -1
- 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/index.js +3 -2
- package/dist/next/tenant.js +35 -4
- package/dist/next/unlock.d.ts +3 -0
- package/dist/next/unlock.js +112 -0
- package/dist/relay/environment.js +1 -1
- package/package.json +2 -1
- package/types/next-headers.d.ts +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## 3.2.0
|
|
9
|
+
|
|
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:
|
|
11
|
+
- New route handler `fontdue-js/next/unlock`. Mount it in your app (`export { POST } from "fontdue-js/next/unlock"` in `app/api/unlock/route.ts`): on a successful unlock it enables Next draft mode, whose bypass cookie makes that one visitor's renders dynamic, so the unlock cookie is read and the collection resolves. Everyone else keeps the static pages.
|
|
12
|
+
- New `unlockEndpoint` prop on `NodePasswordForm` — the form POSTs the access token there before reloading. Pass `unlockEndpoint="/api/unlock"` on statically-rendered Next storefronts; omit it on frameworks that render per request (Astro, React Router 7, TanStack Start), where the cookie alone unlocks.
|
|
13
|
+
- `nodeAccessHeaders()` (`fontdue-js/next`) now reads the unlock cookie only during draft-mode renders, so a locked page's static fill is never flagged dynamic. See [Password-protected collections](https://www.fontdue.com/docs/develop/password-protected-collections).
|
|
14
|
+
|
|
1
15
|
## 3.1.0
|
|
2
16
|
|
|
3
17
|
- **New `FeatureTesters` component** (`fontdue-js/FeatureTesters`) — renders a collection's *feature tester* cards, each showcasing an OpenType feature by toggling it on and off and tinting exactly the characters the feature changes. Pass `collectionId` or `collectionSlug`; in a React Server Component the server entry preloads its own query, and in a client tree (`preloadedQuery` or inside a `<FontdueProvider>`) it hydrates or lazy-loads like the other collection components. Collection-level props override the per-card dashboard settings: `highlightColor`, `toggle` (`'button'` or `'hover'`), and `autofit`. Requires a Fontdue backend that exposes `FontCollection.featureTesters` and the `FeatureTester` node. See [Feature Testers](https://www.fontdue.com/docs/reference/feature-testers).
|
|
@@ -44,22 +44,44 @@ 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,
|
|
56
|
+
cookiesError: undefined,
|
|
57
|
+
jar: new Map()
|
|
52
58
|
}));
|
|
53
59
|
vi.mock('next/headers', () => ({
|
|
54
60
|
draftMode: async () => ({
|
|
55
|
-
isEnabled: draft.enabled
|
|
61
|
+
isEnabled: draft.enabled,
|
|
62
|
+
enable: () => {
|
|
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
|
+
});
|
|
72
|
+
}
|
|
56
73
|
}),
|
|
57
74
|
cookies: async () => {
|
|
58
75
|
if (draft.cookiesError) throw draft.cookiesError;
|
|
59
76
|
return {
|
|
60
77
|
get: name => name === 'fontdue_preview_token' && draft.token ? {
|
|
61
78
|
value: draft.token
|
|
62
|
-
} :
|
|
79
|
+
} : name === 'fontdue_node_access' && draft.nodeAccess ? {
|
|
80
|
+
value: draft.nodeAccess
|
|
81
|
+
} : draft.jar.get(name),
|
|
82
|
+
set: cookie => {
|
|
83
|
+
draft.jar.set(cookie.name, cookie);
|
|
84
|
+
}
|
|
63
85
|
};
|
|
64
86
|
}
|
|
65
87
|
}));
|
|
@@ -74,7 +96,9 @@ beforeEach(() => {
|
|
|
74
96
|
vi.restoreAllMocks();
|
|
75
97
|
draft.enabled = false;
|
|
76
98
|
draft.token = undefined;
|
|
99
|
+
draft.nodeAccess = undefined;
|
|
77
100
|
draft.cookiesError = undefined;
|
|
101
|
+
draft.jar.clear();
|
|
78
102
|
});
|
|
79
103
|
function stubSingleTenant() {
|
|
80
104
|
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
@@ -360,6 +384,156 @@ describe('single-tenant ambient resolver (no per-render call)', () => {
|
|
|
360
384
|
expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
|
|
361
385
|
});
|
|
362
386
|
});
|
|
387
|
+
describe('nodeAccessHeaders', () => {
|
|
388
|
+
it('draft-mode bypass render: returns the forwarding header', async () => {
|
|
389
|
+
stubSingleTenant();
|
|
390
|
+
draft.enabled = true;
|
|
391
|
+
draft.nodeAccess = 'na-tok';
|
|
392
|
+
const {
|
|
393
|
+
nodeAccessHeaders
|
|
394
|
+
} = await importTenant();
|
|
395
|
+
expect(await nodeAccessHeaders()).toEqual({
|
|
396
|
+
'fontdue-node-access': 'na-tok'
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
it('draft-mode bypass render without the cookie: returns {}', async () => {
|
|
400
|
+
stubSingleTenant();
|
|
401
|
+
draft.enabled = true;
|
|
402
|
+
const {
|
|
403
|
+
nodeAccessHeaders
|
|
404
|
+
} = await importTenant();
|
|
405
|
+
expect(await nodeAccessHeaders()).toEqual({});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Regression for FD-773: locked slugs are excluded from generateStaticParams,
|
|
409
|
+
// so a visitor's first request is an on-demand STATIC fill of the SSG route.
|
|
410
|
+
// Merely calling cookies() there flags the render as dynamic and Next 15
|
|
411
|
+
// 500s ("Page changed from static to dynamic at runtime") — catching the
|
|
412
|
+
// throw doesn't unpoison it. Off draft mode the hook must return "no token"
|
|
413
|
+
// WITHOUT touching cookies(), so the fill can prerender the password form.
|
|
414
|
+
// cookiesError guards the "without touching" half: any cookies() call would
|
|
415
|
+
// reject.
|
|
416
|
+
it('runtime static fill (no draft mode): no token, and cookies() is never called', async () => {
|
|
417
|
+
stubSingleTenant();
|
|
418
|
+
draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
|
|
419
|
+
digest: 'DYNAMIC_SERVER_USAGE'
|
|
420
|
+
});
|
|
421
|
+
const {
|
|
422
|
+
nodeAccessHeaders
|
|
423
|
+
} = await importTenant();
|
|
424
|
+
expect(await nodeAccessHeaders()).toEqual({});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// At build time the gate is skipped and the bailout keeps propagating: a
|
|
428
|
+
// static route whose own fetch touches a locked collection bails out to
|
|
429
|
+
// dynamic at build today, and the fix must not change build classification.
|
|
430
|
+
it('build-time prerender: still propagates the dynamic bailout', async () => {
|
|
431
|
+
stubSingleTenant();
|
|
432
|
+
vi.stubEnv('NEXT_PHASE', 'phase-production-build');
|
|
433
|
+
draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
|
|
434
|
+
digest: 'DYNAMIC_SERVER_USAGE'
|
|
435
|
+
});
|
|
436
|
+
const {
|
|
437
|
+
nodeAccessHeaders
|
|
438
|
+
} = await importTenant();
|
|
439
|
+
await expect(nodeAccessHeaders()).rejects.toBe(draft.cookiesError);
|
|
440
|
+
});
|
|
441
|
+
it('swallows a non-control-flow throw (no request scope)', async () => {
|
|
442
|
+
stubSingleTenant();
|
|
443
|
+
draft.enabled = true;
|
|
444
|
+
draft.cookiesError = new Error('no request scope');
|
|
445
|
+
const {
|
|
446
|
+
nodeAccessHeaders
|
|
447
|
+
} = await importTenant();
|
|
448
|
+
expect(await nodeAccessHeaders()).toEqual({});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
describe('unlock route handler', () => {
|
|
452
|
+
async function importUnlock() {
|
|
453
|
+
return await import("../next/unlock.js");
|
|
454
|
+
}
|
|
455
|
+
function unlockRequest(body) {
|
|
456
|
+
return new Request('https://storefront.example/api/unlock', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: {
|
|
459
|
+
'content-type': 'application/json'
|
|
460
|
+
},
|
|
461
|
+
body
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
it('POST with a token enables the draft-mode bypass', async () => {
|
|
465
|
+
const {
|
|
466
|
+
POST
|
|
467
|
+
} = await importUnlock();
|
|
468
|
+
const response = await POST(unlockRequest(JSON.stringify({
|
|
469
|
+
token: 'na-tok'
|
|
470
|
+
})));
|
|
471
|
+
expect(response.status).toBe(200);
|
|
472
|
+
expect(await response.json()).toEqual({
|
|
473
|
+
ok: true
|
|
474
|
+
});
|
|
475
|
+
expect(draft.enabled).toBe(true);
|
|
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
|
+
});
|
|
520
|
+
it('rejects a missing token without enabling the bypass', async () => {
|
|
521
|
+
const {
|
|
522
|
+
POST
|
|
523
|
+
} = await importUnlock();
|
|
524
|
+
const response = await POST(unlockRequest(JSON.stringify({})));
|
|
525
|
+
expect(response.status).toBe(400);
|
|
526
|
+
expect(draft.enabled).toBe(false);
|
|
527
|
+
});
|
|
528
|
+
it('rejects a malformed body without enabling the bypass', async () => {
|
|
529
|
+
const {
|
|
530
|
+
POST
|
|
531
|
+
} = await importUnlock();
|
|
532
|
+
const response = await POST(unlockRequest('not json'));
|
|
533
|
+
expect(response.status).toBe(400);
|
|
534
|
+
expect(draft.enabled).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
363
537
|
|
|
364
538
|
// withFontdue detects the route-tree shape from the working directory; give
|
|
365
539
|
// it one with or without src/app/[domain].
|
|
@@ -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", {
|
|
@@ -2,6 +2,17 @@ import React from 'react';
|
|
|
2
2
|
export interface NodePasswordForm_props {
|
|
3
3
|
collectionId?: string | null;
|
|
4
4
|
collectionSlug?: string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Path of the storefront's unlock route (see fontdue-js/next/unlock), e.g.
|
|
7
|
+
* "/api/unlock". On a successful unlock the form POSTs `{ token }` there
|
|
8
|
+
* before reloading. Required on statically-rendered Next storefronts: the
|
|
9
|
+
* route enables the draft-mode bypass that takes this visitor off the
|
|
10
|
+
* full-route cache, so the reload can actually render the unlocked
|
|
11
|
+
* collection instead of re-serving the cached password form. Omit on
|
|
12
|
+
* frameworks that render server responses per request (Astro, React Router,
|
|
13
|
+
* TanStack Start) — there the cookie alone is enough.
|
|
14
|
+
*/
|
|
15
|
+
unlockEndpoint?: string | null;
|
|
5
16
|
}
|
|
6
17
|
/**
|
|
7
18
|
* Password form for a collection you already know is locked. Render it when a
|