fontdue-js 3.0.0-alpha7 → 3.0.0-alpha9
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 +9 -0
- package/README.md +84 -1
- package/dist/__generated__/orderTrackingUpdateOrderTrackingMutation.graphql.js +1 -8
- package/dist/__tests__/networkFetch.test.js +66 -0
- package/dist/__tests__/nextAdapter.test.js +447 -0
- package/dist/components/Cart/CartOrder.js +9 -1
- package/dist/components/Cart/orderTracking.js +8 -15
- package/dist/components/FontStyle/index.d.ts +2 -0
- package/dist/components/FontStyle/index.js +4 -2
- package/dist/components/TypeTester/index.js +3 -1
- package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
- package/dist/components/TypeTester/useTypeTesterStyler.js +70 -20
- package/dist/components/elements/StoreModalUnifiedCheckout.js +8 -0
- package/dist/components/useFontStyle.d.ts +7 -0
- package/dist/components/useFontStyle.js +2 -1
- package/dist/next/config.d.ts +41 -0
- package/dist/next/config.js +193 -0
- package/dist/next/image-loader.d.ts +7 -0
- package/dist/next/image-loader.js +39 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +10 -0
- package/dist/next/revalidate.d.ts +1 -0
- package/dist/next/revalidate.js +37 -0
- package/dist/next/tenant.d.ts +26 -0
- package/dist/next/tenant.js +169 -0
- package/dist/relay/environment.js +11 -1
- package/dist/relay/serverConfig.d.ts +6 -0
- package/dist/vite.js +2 -0
- package/package.json +5 -2
- package/types/next-cache.d.ts +6 -0
- package/types/next-navigation.d.ts +6 -0
- package/vitest.config.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.28.0
|
|
2
|
+
|
|
3
|
+
- Checkout now captures the buyer's **analytics consent and ad attribution** on the order. When the cart or store-modal checkout opens, fontdue-js sends the consent-banner state, anonymous ID, Meta browser IDs (the `_fbp`/`_fbc` cookies), and the page URL to the server, where they're stored on the order. This lets Fontdue emit a server-side *purchase* conversion event (Facebook Conversions API, Google Ads) when the order completes — completion happens in a Stripe webhook, outside the browser — while respecting the buyer's cookie-consent choice: if consent was declined, no identifiers are sent and nothing is forwarded to ad platforms. The call is fire-and-forget and never affects checkout.
|
|
4
|
+
|
|
5
|
+
## 2.27.0
|
|
6
|
+
|
|
7
|
+
- The `TypeTester` `truncate` option now accepts a **number** to cap the sample paragraph at N lines (in addition to `false` = off and `true` = 1 line). Truncation is now ascender/descender-safe at any line-height — it reads the font's vertical metrics so accents and descenders are never clipped — and flows the clipped text into columns, so multi-column truncated specimens render correctly and side-by-side testers keep their toolbars aligned.
|
|
8
|
+
- The `TypeTester` **family selector now lazy-loads its data**. The family list is fetched on first dropdown open (shared once per page) and per-family styles load on demand, instead of fetching the whole font library up front. This substantially speeds up the initial render of pages with type testers, especially with several standalone testers. The dropdown shows a “Loading…” state while data loads.
|
|
9
|
+
|
|
1
10
|
## 2.26.1
|
|
2
11
|
|
|
3
12
|
- Fixed a jitter in the `TypeTester` variable-font axis sliders: dragging a slider no longer shifts the handle position as the instance name and value beside it change width. The instance name now settles shortly after dragging stops, and the value reserves a fixed width for its range.
|
package/README.md
CHANGED
|
@@ -53,7 +53,18 @@ The shape of step 3 and 4 is the only thing that changes between frameworks.
|
|
|
53
53
|
<details>
|
|
54
54
|
<summary><b>Next.js (App Router)</b></summary>
|
|
55
55
|
|
|
56
|
-
No Vite plugin needed
|
|
56
|
+
No Vite plugin needed — wrap your Next config with [`withFontdue`](#nextjs-adapter) instead:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// next.config.mjs
|
|
60
|
+
import { withFontdue } from "fontdue-js/next/config";
|
|
61
|
+
|
|
62
|
+
export default withFontdue({
|
|
63
|
+
// your Next config
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The simplest setup omits the layout preload — with React Server Components, each fontdue-js component preloads its own query internally on the server and streams to the client.
|
|
57
68
|
|
|
58
69
|
```tsx
|
|
59
70
|
// app/layout.tsx
|
|
@@ -84,6 +95,8 @@ export default function FontPage() {
|
|
|
84
95
|
}
|
|
85
96
|
```
|
|
86
97
|
|
|
98
|
+
Beyond the components, Next.js projects get a few extra entry points — config wrapping, cache revalidation, and helpers for your own GraphQL fetches. See [Next.js adapter](#nextjs-adapter).
|
|
99
|
+
|
|
87
100
|
Example repo: [`fontdue/fontdue-example-next`](https://github.com/fontdue/fontdue-example-next)
|
|
88
101
|
|
|
89
102
|
</details>
|
|
@@ -356,6 +369,76 @@ export default function App() {
|
|
|
356
369
|
|
|
357
370
|
</details>
|
|
358
371
|
|
|
372
|
+
## Next.js adapter
|
|
373
|
+
|
|
374
|
+
Next.js App Router projects get a few extra entry points beyond the components. The [example repo](https://github.com/fontdue/fontdue-example-next) wires up all of them.
|
|
375
|
+
|
|
376
|
+
### `withFontdue` — next.config wrapper
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
// next.config.mjs
|
|
380
|
+
import { withFontdue } from "fontdue-js/next/config";
|
|
381
|
+
|
|
382
|
+
export default withFontdue({
|
|
383
|
+
// your Next config
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
What it installs:
|
|
388
|
+
|
|
389
|
+
- **Image settings** — `images.remotePatterns` entries for Fontdue's image hosts (plus `dangerouslyAllowSVG`, since font specimens are often SVGs), merged with your own `images` config.
|
|
390
|
+
- **Correct 404 statuses** — Next's streamed metadata locks in a `200` response before a `notFound()` thrown during `generateMetadata` can take effect ([vercel/next.js#82041](https://github.com/vercel/next.js/issues/82041)). `withFontdue` sets `htmlLimitedBots` to match every user agent so metadata rendering blocks the response and missing pages come out as real 404s.
|
|
391
|
+
|
|
392
|
+
The rest of your config — `rewrites` included — passes through unchanged.
|
|
393
|
+
|
|
394
|
+
#### Optional: image optimization on Cloudflare
|
|
395
|
+
|
|
396
|
+
If you have a Cloudflare zone with [image transformations](https://developers.cloudflare.com/images/transform-images/) enabled, set:
|
|
397
|
+
|
|
398
|
+
```shell
|
|
399
|
+
NEXT_PUBLIC_FONTDUE_IMAGE_HOST=img.your-domain.com
|
|
400
|
+
NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS=cdn.fontdue.com
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`next/image` optimization then moves to the Cloudflare edge, and your deployment needs neither the `/_next/image` endpoint nor sharp. `NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS` (comma-separated hostnames) should mirror the transformation host's allowed source origins — sources on other hosts are served as originals rather than as transform URLs Cloudflare would refuse. Both variables must be present when `next build` runs (the loader is inlined into the client bundle), not just at serve time.
|
|
404
|
+
|
|
405
|
+
### Updating content: `/api/revalidate`
|
|
406
|
+
|
|
407
|
+
fontdue-js's server-side fetches are cached by Next and tagged `graphql`. Re-export the deploy-hook route handler:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// app/api/revalidate/route.ts
|
|
411
|
+
export { POST } from "fontdue-js/next/revalidate";
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
and set the Deploy hook URL in your Fontdue admin (Settings → Website settings) to `https://your-site.example/api/revalidate`. Fontdue calls it whenever your site's content changes, purging everything tagged `graphql` so the next request renders fresh.
|
|
415
|
+
|
|
416
|
+
fontdue-js's own server-side fetches opt into Next's data cache (and the `graphql` tag) automatically — static pages revalidated by the deploy hook is the intended way to run a Fontdue site, not dynamic rendering. Give your own fetches the same treatment; `currentFontdueEndpoint()` below shows how.
|
|
417
|
+
|
|
418
|
+
### Your own GraphQL fetches
|
|
419
|
+
|
|
420
|
+
`currentFontdueEndpoint()` (from `fontdue-js/next`) describes the endpoint your own server-side [GraphQL](https://docs.fontdue.com/graphql-api) fetches should use — the base origin, required headers, and the cache tags that tie them into `/api/revalidate`:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
import { currentFontdueEndpoint } from "fontdue-js/next";
|
|
424
|
+
|
|
425
|
+
export async function fetchGraphql(query: string, variables?: unknown) {
|
|
426
|
+
const endpoint = currentFontdueEndpoint();
|
|
427
|
+
const response = await fetch(`${endpoint.origin}/graphql`, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
body: JSON.stringify({ query, variables }),
|
|
430
|
+
headers: { "content-type": "application/json", ...endpoint.headers },
|
|
431
|
+
// Cache explicitly (Next 15 doesn't cache fetch by default), tagged so
|
|
432
|
+
// the deploy hook purges this too.
|
|
433
|
+
cache: "force-cache",
|
|
434
|
+
next: { tags: endpoint.tags },
|
|
435
|
+
});
|
|
436
|
+
return (await response.json()).data;
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The shape is exported as `type FontdueEndpoint`.
|
|
441
|
+
|
|
359
442
|
## UI config
|
|
360
443
|
|
|
361
444
|
Most components accept a `config` object that controls UI behavior — type-tester options (`selectable`, `priceBar`, size ranges, OpenType-feature UI…), store-modal layout, form styling, analytics tracking, and more. See the [full config reference](https://docs.fontdue.com/fontduejs#b3dec49aa08240bba2b4c71a67c08333).
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
Object.defineProperty(exports, "__esModule", {
|
|
4
|
-
value: true
|
|
5
|
-
});
|
|
6
|
-
exports.default = void 0;
|
|
7
1
|
/**
|
|
8
2
|
* @generated SignedSource<<017a8a724b3a0fd0918153ce04d07f68>>
|
|
9
3
|
* @lightSyntaxTransform
|
|
@@ -68,5 +62,4 @@ const node = function () {
|
|
|
68
62
|
};
|
|
69
63
|
}();
|
|
70
64
|
node.hash = "d59a127a7f140424f507ae549731bac7";
|
|
71
|
-
|
|
72
|
-
exports.default = _default;
|
|
65
|
+
export default node;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
// environment.ts reads env at module load, so stub env and re-import fresh.
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
vi.unstubAllEnvs();
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
});
|
|
8
|
+
const request = {
|
|
9
|
+
name: 'TestQuery',
|
|
10
|
+
text: 'query TestQuery { viewer { id } }'
|
|
11
|
+
};
|
|
12
|
+
describe('createNetworkFetch (server)', () => {
|
|
13
|
+
it('opts fetches into the Next data cache, tagged for revalidation', async () => {
|
|
14
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
15
|
+
const fetchMock = vi.fn(async () => ({
|
|
16
|
+
json: async () => ({
|
|
17
|
+
data: {}
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
21
|
+
const {
|
|
22
|
+
createNetworkFetch
|
|
23
|
+
} = await import("../relay/environment.js");
|
|
24
|
+
await createNetworkFetch()(request, {});
|
|
25
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
26
|
+
const [url, options] = fetchMock.mock.calls[0];
|
|
27
|
+
expect(url).toBe('https://acme.fontdue.com/graphql?queryName=TestQuery');
|
|
28
|
+
// Without an explicit cache option Next 15 treats the fetch as no-store
|
|
29
|
+
// and silently makes every page fully dynamic — the static + revalidate
|
|
30
|
+
// pattern depends on this.
|
|
31
|
+
expect(options.cache).toBe('force-cache');
|
|
32
|
+
expect(options.next.tags).toContain('graphql');
|
|
33
|
+
expect(options.next.tags).toContain('operation:TestQuery');
|
|
34
|
+
});
|
|
35
|
+
it('applies the per-render server config tags and headers', async () => {
|
|
36
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
37
|
+
const fetchMock = vi.fn(async () => ({
|
|
38
|
+
json: async () => ({
|
|
39
|
+
data: {}
|
|
40
|
+
})
|
|
41
|
+
}));
|
|
42
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
43
|
+
|
|
44
|
+
// React.cache doesn't memoize outside a React render, so the store is
|
|
45
|
+
// mocked rather than set through setFontdueServerConfig.
|
|
46
|
+
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
47
|
+
...(await importActual()),
|
|
48
|
+
getFontdueServerConfig: () => ({
|
|
49
|
+
url: 'http://app:4000',
|
|
50
|
+
headers: {
|
|
51
|
+
'x-forwarded-host': 'acme.fontdue.com'
|
|
52
|
+
},
|
|
53
|
+
cacheTags: ['graphql:acme.fontdue.com']
|
|
54
|
+
})
|
|
55
|
+
}));
|
|
56
|
+
const {
|
|
57
|
+
createNetworkFetch
|
|
58
|
+
} = await import("../relay/environment.js");
|
|
59
|
+
await createNetworkFetch()(request, {});
|
|
60
|
+
vi.doUnmock('../relay/serverConfig');
|
|
61
|
+
const [url, options] = fetchMock.mock.calls[0];
|
|
62
|
+
expect(url).toBe('http://app:4000/graphql?queryName=TestQuery');
|
|
63
|
+
expect(options.headers['x-forwarded-host']).toBe('acme.fontdue.com');
|
|
64
|
+
expect(options.next.tags).toContain('graphql:acme.fontdue.com');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// The next adapter modules read process.env at module load, so each test
|
|
7
|
+
// group stubs the env and re-imports them fresh.
|
|
8
|
+
async function importTenant() {
|
|
9
|
+
return await import("../next/tenant.js");
|
|
10
|
+
}
|
|
11
|
+
async function importConfig() {
|
|
12
|
+
return await import("../next/config.js");
|
|
13
|
+
}
|
|
14
|
+
async function importRevalidate() {
|
|
15
|
+
return await import("../next/revalidate.js");
|
|
16
|
+
}
|
|
17
|
+
const revalidateTag = vi.fn();
|
|
18
|
+
vi.mock('next/cache', () => ({
|
|
19
|
+
revalidateTag: tag => revalidateTag(tag)
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Next's notFound() throws a sentinel the framework catches; the mock does
|
|
23
|
+
// the same so tests can assert on it.
|
|
24
|
+
vi.mock('next/navigation', () => ({
|
|
25
|
+
notFound: () => {
|
|
26
|
+
throw new Error('NEXT_NOT_FOUND');
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetModules();
|
|
31
|
+
revalidateTag.mockClear();
|
|
32
|
+
vi.unstubAllEnvs();
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
function stubSingleTenant() {
|
|
36
|
+
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
37
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', url);
|
|
38
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
39
|
+
vi.stubEnv('FONTDUE_ORIGIN', '');
|
|
40
|
+
vi.stubEnv('FONTDUE_PROXY_SECRET', '');
|
|
41
|
+
}
|
|
42
|
+
function stubMultiTenant() {
|
|
43
|
+
let {
|
|
44
|
+
origin = '',
|
|
45
|
+
secret = ''
|
|
46
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
47
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
48
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '1');
|
|
49
|
+
vi.stubEnv('FONTDUE_ORIGIN', origin);
|
|
50
|
+
vi.stubEnv('FONTDUE_PROXY_SECRET', secret);
|
|
51
|
+
}
|
|
52
|
+
describe('isValidDomain', () => {
|
|
53
|
+
it('accepts plain dotted hostnames and rejects everything else', async () => {
|
|
54
|
+
stubMultiTenant();
|
|
55
|
+
const {
|
|
56
|
+
isValidDomain
|
|
57
|
+
} = await importTenant();
|
|
58
|
+
expect(isValidDomain('acme.fontdue.com')).toBe(true);
|
|
59
|
+
expect(isValidDomain('a-b.example')).toBe(true);
|
|
60
|
+
expect(isValidDomain('localhost')).toBe(false); // no dot
|
|
61
|
+
expect(isValidDomain('acme.fontdue.com:3000')).toBe(false); // port
|
|
62
|
+
expect(isValidDomain('acme.fontdue.com/x')).toBe(false); // path
|
|
63
|
+
expect(isValidDomain('-bad.example')).toBe(false);
|
|
64
|
+
expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('fontdueEndpoint', () => {
|
|
68
|
+
it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
|
|
69
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
70
|
+
const {
|
|
71
|
+
fontdueEndpoint
|
|
72
|
+
} = await importTenant();
|
|
73
|
+
expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
|
|
74
|
+
domain: 'acme.fontdue.com',
|
|
75
|
+
origin: 'https://acme.fontdue.com',
|
|
76
|
+
headers: {},
|
|
77
|
+
tags: ['graphql', 'graphql:acme.fontdue.com']
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
it('single-tenant: throws when NEXT_PUBLIC_FONTDUE_URL is missing', async () => {
|
|
81
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
82
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
83
|
+
const {
|
|
84
|
+
fontdueEndpoint
|
|
85
|
+
} = await importTenant();
|
|
86
|
+
expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
87
|
+
});
|
|
88
|
+
it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
|
|
89
|
+
stubMultiTenant({
|
|
90
|
+
origin: 'http://app:4000',
|
|
91
|
+
secret: 's3cret'
|
|
92
|
+
});
|
|
93
|
+
const {
|
|
94
|
+
fontdueEndpoint
|
|
95
|
+
} = await importTenant();
|
|
96
|
+
expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
|
|
97
|
+
domain: 'acme.fontdue.com',
|
|
98
|
+
origin: 'http://app:4000',
|
|
99
|
+
headers: {
|
|
100
|
+
'x-forwarded-host': 'acme.fontdue.com',
|
|
101
|
+
'x-fontdue-proxy-secret': 's3cret'
|
|
102
|
+
},
|
|
103
|
+
tags: ['graphql', 'graphql:acme.fontdue.com']
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
it('multi-tenant: omits the secret header when unset', async () => {
|
|
107
|
+
stubMultiTenant({
|
|
108
|
+
origin: 'http://app:4000'
|
|
109
|
+
});
|
|
110
|
+
const {
|
|
111
|
+
fontdueEndpoint
|
|
112
|
+
} = await importTenant();
|
|
113
|
+
expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
|
|
114
|
+
'x-forwarded-host': 'acme.fontdue.com'
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
|
|
118
|
+
stubMultiTenant();
|
|
119
|
+
const {
|
|
120
|
+
fontdueEndpoint
|
|
121
|
+
} = await importTenant();
|
|
122
|
+
expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('configureFontdueRender', () => {
|
|
126
|
+
it('rejects invalid domains with null and sets no config', async () => {
|
|
127
|
+
stubMultiTenant({
|
|
128
|
+
origin: 'http://app:4000'
|
|
129
|
+
});
|
|
130
|
+
const {
|
|
131
|
+
configureFontdueRender
|
|
132
|
+
} = await importTenant();
|
|
133
|
+
const {
|
|
134
|
+
getFontdueServerConfig
|
|
135
|
+
} = await import("../relay/serverConfig.js");
|
|
136
|
+
expect(configureFontdueRender('not a domain')).toBeNull();
|
|
137
|
+
expect(getFontdueServerConfig()).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
it('sets the per-render server config and returns the endpoint', async () => {
|
|
140
|
+
stubMultiTenant({
|
|
141
|
+
origin: 'http://app:4000',
|
|
142
|
+
secret: 's3cret'
|
|
143
|
+
});
|
|
144
|
+
const {
|
|
145
|
+
configureFontdueRender
|
|
146
|
+
} = await importTenant();
|
|
147
|
+
const {
|
|
148
|
+
getFontdueServerConfig
|
|
149
|
+
} = await import("../relay/serverConfig.js");
|
|
150
|
+
const endpoint = configureFontdueRender('acme.fontdue.com');
|
|
151
|
+
expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
|
|
152
|
+
// Outside an RSC render React.cache doesn't memoize, so the write is a
|
|
153
|
+
// no-op here — but the config passed must still be shaped correctly, so
|
|
154
|
+
// assert via fontdueServerConfig instead.
|
|
155
|
+
const {
|
|
156
|
+
fontdueServerConfig
|
|
157
|
+
} = await importTenant();
|
|
158
|
+
expect(fontdueServerConfig('acme.fontdue.com')).toEqual({
|
|
159
|
+
url: 'http://app:4000',
|
|
160
|
+
headers: {
|
|
161
|
+
'x-forwarded-host': 'acme.fontdue.com',
|
|
162
|
+
'x-fontdue-proxy-secret': 's3cret'
|
|
163
|
+
},
|
|
164
|
+
cacheTags: ['graphql:acme.fontdue.com'],
|
|
165
|
+
domain: 'acme.fontdue.com'
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('prepareFontdueRender', () => {
|
|
170
|
+
const props = params => ({
|
|
171
|
+
params: Promise.resolve(params)
|
|
172
|
+
});
|
|
173
|
+
it('configures the render and returns the endpoint', async () => {
|
|
174
|
+
stubMultiTenant({
|
|
175
|
+
origin: 'http://app:4000'
|
|
176
|
+
});
|
|
177
|
+
const {
|
|
178
|
+
prepareFontdueRender
|
|
179
|
+
} = await importTenant();
|
|
180
|
+
const endpoint = await prepareFontdueRender(props({
|
|
181
|
+
domain: 'acme.fontdue.com',
|
|
182
|
+
slug: 'sans'
|
|
183
|
+
}));
|
|
184
|
+
expect(endpoint.origin).toBe('http://app:4000');
|
|
185
|
+
expect(endpoint.tags).toContain('graphql:acme.fontdue.com');
|
|
186
|
+
});
|
|
187
|
+
it('404s invalid or missing domains', async () => {
|
|
188
|
+
stubMultiTenant();
|
|
189
|
+
const {
|
|
190
|
+
prepareFontdueRender
|
|
191
|
+
} = await importTenant();
|
|
192
|
+
await expect(prepareFontdueRender(props({
|
|
193
|
+
domain: 'not a domain'
|
|
194
|
+
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
195
|
+
await expect(prepareFontdueRender(props({
|
|
196
|
+
slug: 'sans'
|
|
197
|
+
}))).rejects.toThrow('NEXT_NOT_FOUND');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('currentFontdueEndpoint', () => {
|
|
201
|
+
it('multi-tenant: throws when no render config was set', async () => {
|
|
202
|
+
stubMultiTenant({
|
|
203
|
+
origin: 'http://app:4000'
|
|
204
|
+
});
|
|
205
|
+
const {
|
|
206
|
+
currentFontdueEndpoint
|
|
207
|
+
} = await importTenant();
|
|
208
|
+
expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
|
|
209
|
+
});
|
|
210
|
+
it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
|
|
211
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
212
|
+
const {
|
|
213
|
+
currentFontdueEndpoint
|
|
214
|
+
} = await importTenant();
|
|
215
|
+
expect(currentFontdueEndpoint()).toEqual({
|
|
216
|
+
domain: 'acme.fontdue.com',
|
|
217
|
+
origin: 'https://acme.fontdue.com',
|
|
218
|
+
headers: {},
|
|
219
|
+
tags: ['graphql', 'graphql:acme.fontdue.com']
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
it('uses the render-configured domain when set', async () => {
|
|
223
|
+
stubMultiTenant({
|
|
224
|
+
origin: 'http://app:4000'
|
|
225
|
+
});
|
|
226
|
+
vi.doMock('../relay/serverConfig', async importActual => ({
|
|
227
|
+
...(await importActual()),
|
|
228
|
+
getFontdueServerConfig: () => ({
|
|
229
|
+
domain: 'acme.fontdue.com'
|
|
230
|
+
})
|
|
231
|
+
}));
|
|
232
|
+
const {
|
|
233
|
+
currentFontdueEndpoint
|
|
234
|
+
} = await importTenant();
|
|
235
|
+
expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
|
|
236
|
+
vi.doUnmock('../relay/serverConfig');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('generateStaticParams', () => {
|
|
240
|
+
it('returns no build-time params (everything renders on demand)', async () => {
|
|
241
|
+
stubMultiTenant();
|
|
242
|
+
const {
|
|
243
|
+
generateStaticParams
|
|
244
|
+
} = await importTenant();
|
|
245
|
+
await expect(generateStaticParams()).resolves.toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// withFontdue detects the route-tree shape from the working directory; give
|
|
250
|
+
// it one with or without src/app/[domain].
|
|
251
|
+
function mockAppDir(_ref) {
|
|
252
|
+
let {
|
|
253
|
+
domainTree
|
|
254
|
+
} = _ref;
|
|
255
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fontdue-app-'));
|
|
256
|
+
fs.mkdirSync(path.join(dir, domainTree ? 'src/app/[domain]' : 'src/app'), {
|
|
257
|
+
recursive: true
|
|
258
|
+
});
|
|
259
|
+
vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
|
260
|
+
}
|
|
261
|
+
describe('withFontdue', () => {
|
|
262
|
+
beforeEach(() => mockAppDir({
|
|
263
|
+
domainTree: true
|
|
264
|
+
}));
|
|
265
|
+
it('throws when neither mode is configured', async () => {
|
|
266
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
267
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
268
|
+
const {
|
|
269
|
+
withFontdue
|
|
270
|
+
} = await importConfig();
|
|
271
|
+
expect(() => withFontdue({})).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
272
|
+
});
|
|
273
|
+
it('single-tenant: rewrites every path under the constant domain', async () => {
|
|
274
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
275
|
+
const {
|
|
276
|
+
withFontdue
|
|
277
|
+
} = await importConfig();
|
|
278
|
+
const rewrites = await withFontdue({}).rewrites();
|
|
279
|
+
expect(rewrites.afterFiles).toEqual([]);
|
|
280
|
+
expect(rewrites.fallback).toEqual([]);
|
|
281
|
+
const destinations = rewrites.beforeFiles.map(r => r.destination);
|
|
282
|
+
expect(destinations).toEqual(['/acme.fontdue.com', '/acme.fontdue.com/robots.txt', '/acme.fontdue.com/sitemap.xml', '/acme.fontdue.com/:path']);
|
|
283
|
+
});
|
|
284
|
+
it('multi-tenant: emits forwarded-host rules before host rules, mutually exclusive', async () => {
|
|
285
|
+
stubMultiTenant();
|
|
286
|
+
const {
|
|
287
|
+
withFontdue
|
|
288
|
+
} = await importConfig();
|
|
289
|
+
const rewrites = await withFontdue({}).rewrites();
|
|
290
|
+
expect(rewrites.beforeFiles).toHaveLength(8);
|
|
291
|
+
const [forwarded, hostBased] = [rewrites.beforeFiles.slice(0, 4), rewrites.beforeFiles.slice(4)];
|
|
292
|
+
for (const rule of forwarded) {
|
|
293
|
+
var _rule$has;
|
|
294
|
+
expect((_rule$has = rule.has) === null || _rule$has === void 0 ? void 0 : _rule$has[0]).toMatchObject({
|
|
295
|
+
type: 'header',
|
|
296
|
+
key: 'x-forwarded-host'
|
|
297
|
+
});
|
|
298
|
+
expect(rule.missing).toBeUndefined();
|
|
299
|
+
}
|
|
300
|
+
for (const rule of hostBased) {
|
|
301
|
+
var _rule$has2, _rule$missing;
|
|
302
|
+
expect((_rule$has2 = rule.has) === null || _rule$has2 === void 0 ? void 0 : _rule$has2[0]).toMatchObject({
|
|
303
|
+
type: 'host'
|
|
304
|
+
});
|
|
305
|
+
expect((_rule$missing = rule.missing) === null || _rule$missing === void 0 ? void 0 : _rule$missing[0]).toMatchObject({
|
|
306
|
+
type: 'header',
|
|
307
|
+
key: 'x-forwarded-host'
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
it("chains the app's beforeFiles rules ahead of the tenant rules", async () => {
|
|
312
|
+
stubSingleTenant();
|
|
313
|
+
const {
|
|
314
|
+
withFontdue
|
|
315
|
+
} = await importConfig();
|
|
316
|
+
const userRule = {
|
|
317
|
+
source: '/old',
|
|
318
|
+
destination: '/new'
|
|
319
|
+
};
|
|
320
|
+
const config = withFontdue({
|
|
321
|
+
rewrites: async () => ({
|
|
322
|
+
beforeFiles: [userRule]
|
|
323
|
+
})
|
|
324
|
+
});
|
|
325
|
+
const rewrites = await config.rewrites();
|
|
326
|
+
expect(rewrites.beforeFiles[0]).toEqual(userRule);
|
|
327
|
+
expect(rewrites.beforeFiles).toHaveLength(5);
|
|
328
|
+
});
|
|
329
|
+
it("treats a plain-array rewrites() result as afterFiles, per Next's contract", async () => {
|
|
330
|
+
stubSingleTenant();
|
|
331
|
+
const {
|
|
332
|
+
withFontdue
|
|
333
|
+
} = await importConfig();
|
|
334
|
+
const userRule = {
|
|
335
|
+
source: '/old',
|
|
336
|
+
destination: '/new'
|
|
337
|
+
};
|
|
338
|
+
const rewrites = await withFontdue({
|
|
339
|
+
rewrites: async () => [userRule]
|
|
340
|
+
}).rewrites();
|
|
341
|
+
expect(rewrites.afterFiles).toEqual([userRule]);
|
|
342
|
+
expect(rewrites.beforeFiles).toHaveLength(4);
|
|
343
|
+
});
|
|
344
|
+
it('merges image settings, keeping app remotePatterns and overrides', async () => {
|
|
345
|
+
stubMultiTenant();
|
|
346
|
+
const {
|
|
347
|
+
withFontdue
|
|
348
|
+
} = await importConfig();
|
|
349
|
+
const config = withFontdue({
|
|
350
|
+
images: {
|
|
351
|
+
dangerouslyAllowSVG: false,
|
|
352
|
+
remotePatterns: [{
|
|
353
|
+
protocol: 'https',
|
|
354
|
+
hostname: 'cdn.example'
|
|
355
|
+
}]
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
expect(config.images.dangerouslyAllowSVG).toBe(false);
|
|
359
|
+
// NODE_ENV is 'test' here, i.e. not production → dev workaround active.
|
|
360
|
+
expect(config.images.unoptimized).toBe(true);
|
|
361
|
+
const hostnames = config.images.remotePatterns.map(p => p.hostname);
|
|
362
|
+
expect(hostnames).toContain('cdn.example');
|
|
363
|
+
expect(hostnames).toContain('*.fontdue.com');
|
|
364
|
+
expect(hostnames).toContain('**');
|
|
365
|
+
});
|
|
366
|
+
it('passes unrelated config through and defaults htmlLimitedBots', async () => {
|
|
367
|
+
stubSingleTenant();
|
|
368
|
+
const {
|
|
369
|
+
withFontdue
|
|
370
|
+
} = await importConfig();
|
|
371
|
+
const config = withFontdue({
|
|
372
|
+
reactStrictMode: true
|
|
373
|
+
});
|
|
374
|
+
expect(config.reactStrictMode).toBe(true);
|
|
375
|
+
expect(config.htmlLimitedBots).toEqual(/.*/);
|
|
376
|
+
expect(withFontdue({
|
|
377
|
+
htmlLimitedBots: /Googlebot/
|
|
378
|
+
}).htmlLimitedBots).toEqual(/Googlebot/);
|
|
379
|
+
});
|
|
380
|
+
it('single-tenant flat tree: installs no tenant rewrites', async () => {
|
|
381
|
+
mockAppDir({
|
|
382
|
+
domainTree: false
|
|
383
|
+
});
|
|
384
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
385
|
+
const {
|
|
386
|
+
withFontdue
|
|
387
|
+
} = await importConfig();
|
|
388
|
+
const userRule = {
|
|
389
|
+
source: '/old',
|
|
390
|
+
destination: '/new'
|
|
391
|
+
};
|
|
392
|
+
const rewrites = await withFontdue({
|
|
393
|
+
rewrites: async () => ({
|
|
394
|
+
beforeFiles: [userRule]
|
|
395
|
+
})
|
|
396
|
+
}).rewrites();
|
|
397
|
+
expect(rewrites.beforeFiles).toEqual([userRule]);
|
|
398
|
+
expect(rewrites.afterFiles).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
it('multi-tenant flat tree: refuses to start', async () => {
|
|
401
|
+
mockAppDir({
|
|
402
|
+
domainTree: false
|
|
403
|
+
});
|
|
404
|
+
stubMultiTenant();
|
|
405
|
+
const {
|
|
406
|
+
withFontdue
|
|
407
|
+
} = await importConfig();
|
|
408
|
+
expect(() => withFontdue({})).toThrow(/\[domain\]/);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
describe('revalidate POST', () => {
|
|
412
|
+
it('multi-tenant: purges only the tenant tag', async () => {
|
|
413
|
+
stubMultiTenant();
|
|
414
|
+
const {
|
|
415
|
+
POST
|
|
416
|
+
} = await importRevalidate();
|
|
417
|
+
const response = await POST(new Request('http://internal/api/revalidate?domain=Acme.Fontdue.com', {
|
|
418
|
+
method: 'POST'
|
|
419
|
+
}));
|
|
420
|
+
expect(response.status).toBe(200);
|
|
421
|
+
expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql:acme.fontdue.com');
|
|
422
|
+
});
|
|
423
|
+
it('multi-tenant: 400s on a missing or invalid domain', async () => {
|
|
424
|
+
stubMultiTenant();
|
|
425
|
+
const {
|
|
426
|
+
POST
|
|
427
|
+
} = await importRevalidate();
|
|
428
|
+
for (const url of ['http://internal/api/revalidate', 'http://internal/api/revalidate?domain=not%20a%20domain']) {
|
|
429
|
+
const response = await POST(new Request(url, {
|
|
430
|
+
method: 'POST'
|
|
431
|
+
}));
|
|
432
|
+
expect(response.status).toBe(400);
|
|
433
|
+
}
|
|
434
|
+
expect(revalidateTag).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
436
|
+
it('single-tenant: purges the global graphql tag', async () => {
|
|
437
|
+
stubSingleTenant();
|
|
438
|
+
const {
|
|
439
|
+
POST
|
|
440
|
+
} = await importRevalidate();
|
|
441
|
+
const response = await POST(new Request('http://internal/api/revalidate', {
|
|
442
|
+
method: 'POST'
|
|
443
|
+
}));
|
|
444
|
+
expect(response.status).toBe(200);
|
|
445
|
+
expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
@@ -4,7 +4,7 @@ import _CartOrderRemoveDiscountMutation from "../../__generated__/CartOrderRemov
|
|
|
4
4
|
import _CartOrderUpdateMutation from "../../__generated__/CartOrderUpdateMutation.graphql.js";
|
|
5
5
|
import _CartOrder_UpdateErrors from "../../__generated__/CartOrder_UpdateErrors.graphql.js";
|
|
6
6
|
import _CartOrderCompleteOrderMutation from "../../__generated__/CartOrderCompleteOrderMutation.graphql.js";
|
|
7
|
-
import React, { Fragment, useRef, useState } from 'react';
|
|
7
|
+
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
|
|
9
9
|
import Checkout from './Checkout.js';
|
|
10
10
|
import CheckoutSteps from './CheckoutSteps.js';
|
|
@@ -21,6 +21,7 @@ import { textVariablesAllHaveText } from './utils.js';
|
|
|
21
21
|
import { useStripe } from '@stripe/react-stripe-js';
|
|
22
22
|
import { useDispatch } from 'react-redux';
|
|
23
23
|
import CartState from './CartState.js';
|
|
24
|
+
import { sendOrderTracking } from './orderTracking.js';
|
|
24
25
|
|
|
25
26
|
// note here we're only updating the order (by id).
|
|
26
27
|
// careful not to update the current customer's reference
|
|
@@ -44,6 +45,13 @@ const CartOrder = _ref => {
|
|
|
44
45
|
const environment = useRelayEnvironment();
|
|
45
46
|
const stripe = useStripe();
|
|
46
47
|
const dispatch = useDispatch();
|
|
48
|
+
|
|
49
|
+
// Capture analytics consent + attribution on the order for the purchase
|
|
50
|
+
// event the server emits on completion (from a Stripe webhook, where no
|
|
51
|
+
// browser context exists).
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open) sendOrderTracking(environment);
|
|
54
|
+
}, [open, environment]);
|
|
47
55
|
const order = useFragment((_CartOrder_order.hash && _CartOrder_order.hash !== "25ef000c40000c4f76f973ac6ac7f593" && console.error("The definition of 'CartOrder_order' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_order), orderKey);
|
|
48
56
|
const viewer = useFragment((_CartOrder_viewer.hash && _CartOrder_viewer.hash !== "8cf5ab5a33a474483a6c231cc44b0df1" && console.error("The definition of 'CartOrder_viewer' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_viewer), viewerKey);
|
|
49
57
|
|