@akinon/pz-theme 2.0.106-rc.0 → 2.0.107-beta.0
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 +15 -35
- package/package.json +4 -4
- package/src/__tests__/reset-password-composed.test.tsx +19 -1
- package/src/__tests__/reset-password-v1-core.test.tsx +154 -0
- package/src/blocks/__tests__/image-gallery-lightbox-modal.test.tsx +80 -0
- package/src/blocks/image-gallery-block.tsx +13 -1
- package/src/blocks/reset-password-block.tsx +8 -5
- package/src/blocks/reset-password-token-check.ts +69 -0
- package/src/chrome/__tests__/theme-fonts-link.test.tsx +36 -0
- package/src/chrome/theme-fonts-link.tsx +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d458]
|
|
35
|
-
- Updated dependencies [dca12811]
|
|
36
|
-
- @akinon/next@2.0.106-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.106-rc.0
|
|
3
|
+
## 2.0.107-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.107-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.107-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.106
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- e083ee2: ZERO-5078: Keep pz-theme buildable on v1 brands (Next 14.2, React 18 types, @akinon/next 1.x)
|
|
16
|
+
- @akinon/next@2.0.106
|
|
17
|
+
- @akinon/theme-contract@2.0.106
|
|
38
18
|
|
|
39
19
|
## 2.0.105
|
|
40
20
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.107-beta.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.107-beta.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.107-beta.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.
|
|
24
|
+
"@akinon/next": "2.0.107-beta.0",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -17,6 +17,7 @@ import { Block } from '../theme-block';
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const passwordReset = jest.fn();
|
|
20
|
+
const validateToken = jest.fn();
|
|
20
21
|
const unwrap = jest.fn();
|
|
21
22
|
let resetState = { isLoading: false, isSuccess: false };
|
|
22
23
|
let validation: {
|
|
@@ -34,7 +35,11 @@ jest.mock('@akinon/next/data/client/account', () => ({
|
|
|
34
35
|
},
|
|
35
36
|
resetState
|
|
36
37
|
],
|
|
37
|
-
useGetValidatePasswordResetTokenQuery: () =>
|
|
38
|
+
useGetValidatePasswordResetTokenQuery: (...args: unknown[]) => {
|
|
39
|
+
validateToken(...args);
|
|
40
|
+
|
|
41
|
+
return validation;
|
|
42
|
+
}
|
|
38
43
|
}));
|
|
39
44
|
|
|
40
45
|
let params: Record<string, string | string[]> = { id: ['tok', 'abc123'] };
|
|
@@ -112,6 +117,7 @@ const fill = (container: HTMLElement, first: string, second: string) => {
|
|
|
112
117
|
|
|
113
118
|
beforeEach(() => {
|
|
114
119
|
passwordReset.mockClear();
|
|
120
|
+
validateToken.mockClear();
|
|
115
121
|
unwrap.mockReset();
|
|
116
122
|
unwrap.mockResolvedValue(undefined);
|
|
117
123
|
resetState = { isLoading: false, isSuccess: false };
|
|
@@ -154,6 +160,18 @@ describe('the link the mail carries', () => {
|
|
|
154
160
|
expect(passwordReset.mock.calls[0][0].slug).toBe('xyz789');
|
|
155
161
|
});
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The query is reached through `reset-password-token-check` so that a brand
|
|
165
|
+
* on the 1.x core can still build (the export is 2.x only). On a core that
|
|
166
|
+
* HAS it, that indirection must be invisible: same query, same token, same
|
|
167
|
+
* skip.
|
|
168
|
+
*/
|
|
169
|
+
it('is put to the core query itself where the core has one', () => {
|
|
170
|
+
renderBlock();
|
|
171
|
+
|
|
172
|
+
expect(validateToken).toHaveBeenCalledWith('tok/abc123', { skip: false });
|
|
173
|
+
});
|
|
174
|
+
|
|
157
175
|
it('answers a spent link with the expired state, not with a form', () => {
|
|
158
176
|
validation = { data: { validlink: false }, isLoading: false, isError: false };
|
|
159
177
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import ResetPasswordBlock from '../blocks/reset-password-block';
|
|
5
|
+
import {
|
|
6
|
+
canValidateResetToken,
|
|
7
|
+
useValidateResetToken
|
|
8
|
+
} from '../blocks/reset-password-token-check';
|
|
9
|
+
import { Block } from '../theme-block';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same screen on a core that cannot check the link up front.
|
|
13
|
+
*
|
|
14
|
+
* `useGetValidatePasswordResetTokenQuery` is a 2.x export, and most brands are
|
|
15
|
+
* still on the 1.x line. They transpile and type-check this package from
|
|
16
|
+
* `src`, so importing that name by hand failed their `next build` outright —
|
|
17
|
+
* and reaching for it at runtime instead would have left `undefined` where a
|
|
18
|
+
* hook is called. These cases pin what happens instead: nothing throws, the
|
|
19
|
+
* shopper is shown the two boxes, and the link's validity is settled by the
|
|
20
|
+
* submit, through the refusal path that already existed.
|
|
21
|
+
*
|
|
22
|
+
* The mock below is a 1.x `account` module: `usePasswordResetMutation` and no
|
|
23
|
+
* pre-check.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const passwordReset = jest.fn();
|
|
27
|
+
const unwrap = jest.fn();
|
|
28
|
+
|
|
29
|
+
jest.mock('@akinon/next/data/client/account', () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
usePasswordResetMutation: () => [
|
|
32
|
+
(...args: unknown[]) => {
|
|
33
|
+
passwordReset(...args);
|
|
34
|
+
|
|
35
|
+
return { unwrap };
|
|
36
|
+
},
|
|
37
|
+
{ isLoading: false, isSuccess: false }
|
|
38
|
+
]
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('next/navigation', () => ({
|
|
42
|
+
useParams: () => ({ id: ['tok', 'abc123'] }),
|
|
43
|
+
useSearchParams: () => new URLSearchParams(),
|
|
44
|
+
usePathname: () => '/users/reset/tok/abc123'
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
48
|
+
({
|
|
49
|
+
id: 'reset-1',
|
|
50
|
+
type: 'reset-password',
|
|
51
|
+
label: 'Reset Password',
|
|
52
|
+
value: '',
|
|
53
|
+
order: 0,
|
|
54
|
+
hidden: false,
|
|
55
|
+
properties: {},
|
|
56
|
+
styles: {},
|
|
57
|
+
...overrides
|
|
58
|
+
} as Block);
|
|
59
|
+
|
|
60
|
+
const renderBlock = (properties = {}) =>
|
|
61
|
+
render(
|
|
62
|
+
<ResetPasswordBlock
|
|
63
|
+
block={block({ properties })}
|
|
64
|
+
placeholderId="placeholder-1"
|
|
65
|
+
sectionId="section-1"
|
|
66
|
+
currentBreakpoint="desktop"
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const fill = (container: HTMLElement, first: string, second: string) => {
|
|
71
|
+
fireEvent.change(container.querySelector('input[name="password"]')!, {
|
|
72
|
+
target: { value: first }
|
|
73
|
+
});
|
|
74
|
+
fireEvent.change(container.querySelector('input[name="repeatPassword"]')!, {
|
|
75
|
+
target: { value: second }
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
passwordReset.mockClear();
|
|
81
|
+
unwrap.mockReset();
|
|
82
|
+
unwrap.mockResolvedValue(undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('a core without the pre-check', () => {
|
|
86
|
+
it('is recognised rather than assumed', () => {
|
|
87
|
+
expect(canValidateResetToken).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('still answers in a hook position, so the screen mounts', () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
useValidateResetToken('tok/abc123', { skip: false })
|
|
93
|
+
).not.toThrow();
|
|
94
|
+
expect(useValidateResetToken('tok/abc123', { skip: false })).toEqual({
|
|
95
|
+
data: undefined,
|
|
96
|
+
isLoading: false,
|
|
97
|
+
isError: false
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('offers the two boxes instead of calling the link dead', () => {
|
|
102
|
+
const { container } = renderBlock();
|
|
103
|
+
|
|
104
|
+
// No verdict is not a bad verdict: answering "expired" here would lock
|
|
105
|
+
// every shopper on a 1.x brand out of a link that is perfectly good.
|
|
106
|
+
expect(
|
|
107
|
+
container.querySelector('[data-reset-password-state="expired"]')
|
|
108
|
+
).toBeNull();
|
|
109
|
+
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
|
110
|
+
expect(
|
|
111
|
+
container.querySelector('input[name="repeatPassword"]')
|
|
112
|
+
).not.toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('never shows the checking state, because nothing is being checked', () => {
|
|
116
|
+
const { container } = renderBlock({ checkingLabel: 'Checking the link…' });
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
container.querySelector('[data-reset-password-state="checking"]')
|
|
120
|
+
).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('leaves the verdict to the submit, with the token the mail carried', async () => {
|
|
124
|
+
const { container } = renderBlock();
|
|
125
|
+
|
|
126
|
+
fill(container, 'hunter2A!', 'hunter2A!');
|
|
127
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(passwordReset).toHaveBeenCalled());
|
|
130
|
+
expect(passwordReset).toHaveBeenCalledWith({
|
|
131
|
+
password: 'hunter2A!',
|
|
132
|
+
repeatPassword: 'hunter2A!',
|
|
133
|
+
slug: 'tok/abc123'
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows a refused link as an error on the form it is still on', async () => {
|
|
138
|
+
unwrap.mockRejectedValue({
|
|
139
|
+
data: { non_field_errors: ['This link has already been used.'] }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const { container } = renderBlock();
|
|
143
|
+
|
|
144
|
+
fill(container, 'hunter2A!', 'hunter2A!');
|
|
145
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
146
|
+
|
|
147
|
+
await waitFor(() =>
|
|
148
|
+
expect(
|
|
149
|
+
container.querySelector('[data-reset-password-error]')
|
|
150
|
+
).not.toBeNull()
|
|
151
|
+
);
|
|
152
|
+
expect(container.querySelector('form')).not.toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the lightbox asks of the core Modal.
|
|
6
|
+
*
|
|
7
|
+
* `headerWrapperClassName` is a 2.x `ModalProps` field, and brands on the 1.x
|
|
8
|
+
* core type-check this package from `src` — so it is spread rather than
|
|
9
|
+
* written as a JSX attribute (see the block). That is a type-level move only:
|
|
10
|
+
* this case pins that the prop still reaches the Modal unchanged, which is the
|
|
11
|
+
* whole of the v2 behaviour it was protecting.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const modalProps = jest.fn();
|
|
15
|
+
|
|
16
|
+
jest.mock('@akinon/next/components/modal', () => {
|
|
17
|
+
const ReactModule = require('react');
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
__esModule: true,
|
|
21
|
+
Modal: (props: Record<string, unknown>) => {
|
|
22
|
+
modalProps(props);
|
|
23
|
+
|
|
24
|
+
return props.open
|
|
25
|
+
? ReactModule.createElement(
|
|
26
|
+
'div',
|
|
27
|
+
{ 'data-testid': 'lightbox' },
|
|
28
|
+
props.children as React.ReactNode
|
|
29
|
+
)
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
jest.mock('../../theme-block', () => ({
|
|
36
|
+
__esModule: true,
|
|
37
|
+
default: () => null
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import ImageGalleryBlock from '../image-gallery-block';
|
|
41
|
+
|
|
42
|
+
const baseProps = {
|
|
43
|
+
placeholderId: 'ph-1',
|
|
44
|
+
sectionId: 'sec-1',
|
|
45
|
+
isDesigner: false,
|
|
46
|
+
selectedBlockId: null,
|
|
47
|
+
currentBreakpoint: 'desktop'
|
|
48
|
+
} as any;
|
|
49
|
+
|
|
50
|
+
const block = {
|
|
51
|
+
id: 'gallery-1',
|
|
52
|
+
blocks: [
|
|
53
|
+
{
|
|
54
|
+
id: 'img-a',
|
|
55
|
+
type: 'image',
|
|
56
|
+
value: 'https://cdn/a.jpg',
|
|
57
|
+
order: 1,
|
|
58
|
+
properties: { alt: 'Alpha' }
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
beforeEach(() => modalProps.mockClear());
|
|
64
|
+
|
|
65
|
+
describe('the image gallery lightbox', () => {
|
|
66
|
+
it('still asks the modal to drop its header divider', () => {
|
|
67
|
+
render(<ImageGalleryBlock {...baseProps} block={block} />);
|
|
68
|
+
|
|
69
|
+
fireEvent.click(screen.getAllByRole('button')[0]);
|
|
70
|
+
|
|
71
|
+
expect(modalProps).toHaveBeenCalled();
|
|
72
|
+
expect(modalProps.mock.calls[modalProps.mock.calls.length - 1][0]).toEqual(
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
portalId: 'image-gallery-lightbox',
|
|
75
|
+
open: true,
|
|
76
|
+
headerWrapperClassName: 'border-0'
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -7,6 +7,18 @@ import ThemeBlock from '../theme-block';
|
|
|
7
7
|
import { getResponsiveValue } from '../utils';
|
|
8
8
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Spread onto the lightbox rather than written as a JSX attribute. Brands
|
|
12
|
+
* transpile this file from `src` and type-check it against their own
|
|
13
|
+
* @akinon/next, and 1.x's `ModalProps` has no `headerWrapperClassName` — so
|
|
14
|
+
* `next build` would fail there on a prop v2's Modal accepts. Spread, the
|
|
15
|
+
* lightbox is byte-for-byte the same on v2, and a 1.x Modal ignores the extra
|
|
16
|
+
* prop and keeps its header divider.
|
|
17
|
+
*/
|
|
18
|
+
const LIGHTBOX_HEADER: Record<string, string> = {
|
|
19
|
+
headerWrapperClassName: 'border-0'
|
|
20
|
+
};
|
|
21
|
+
|
|
10
22
|
type GalleryImage = {
|
|
11
23
|
url: string;
|
|
12
24
|
alt: string;
|
|
@@ -215,7 +227,7 @@ const ImageGalleryBlock = ({
|
|
|
215
227
|
title=""
|
|
216
228
|
showCloseButton={true}
|
|
217
229
|
className="w-[92vw] max-w-[1024px] max-h-[90vh] overflow-hidden rounded"
|
|
218
|
-
|
|
230
|
+
{...LIGHTBOX_HEADER}
|
|
219
231
|
>
|
|
220
232
|
<div
|
|
221
233
|
className="relative bg-black"
|
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useContext, useState } from 'react';
|
|
4
4
|
import { useParams, useSearchParams } from 'next/navigation';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
useGetValidatePasswordResetTokenQuery
|
|
8
|
-
} from '@akinon/next/data/client/account';
|
|
5
|
+
import { usePasswordResetMutation } from '@akinon/next/data/client/account';
|
|
6
|
+
import { useValidateResetToken } from './reset-password-token-check';
|
|
9
7
|
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
10
8
|
import { readToggle } from './product-block-utils';
|
|
11
9
|
import {
|
|
@@ -275,12 +273,17 @@ const ResetPasswordBlock = ({
|
|
|
275
273
|
* The link's own validity, asked before the shopper is invited to type
|
|
276
274
|
* anything. Skipped in the canvas — the designer has no token, and asking
|
|
277
275
|
* commerce about one would answer "expired" for every composition.
|
|
276
|
+
*
|
|
277
|
+
* Through `reset-password-token-check` rather than straight off
|
|
278
|
+
* `@akinon/next/data/client/account`: the query is a 2.x export, and on a
|
|
279
|
+
* brand whose core is still 1.x that module has no such name to import. See
|
|
280
|
+
* that file for what the screen does instead.
|
|
278
281
|
*/
|
|
279
282
|
const {
|
|
280
283
|
data: validation,
|
|
281
284
|
isLoading: isValidating,
|
|
282
285
|
isError: validationFailed
|
|
283
|
-
} =
|
|
286
|
+
} = useValidateResetToken(token, {
|
|
284
287
|
skip: !token || isDesigner
|
|
285
288
|
});
|
|
286
289
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as accountData from '@akinon/next/data/client/account';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the reset screen needs to know about the link it was opened with.
|
|
7
|
+
*
|
|
8
|
+
* Only the three readings the block acts on, rather than RTK Query's whole
|
|
9
|
+
* result: the hook behind this is reached through the module object (see
|
|
10
|
+
* below), so its shape has to be written out here instead of inferred from an
|
|
11
|
+
* import.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResetTokenValidation {
|
|
14
|
+
data?: { validlink?: boolean };
|
|
15
|
+
isLoading: boolean;
|
|
16
|
+
isError: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ValidateResetTokenHook = (
|
|
20
|
+
token: string,
|
|
21
|
+
options: { skip: boolean }
|
|
22
|
+
) => ResetTokenValidation;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* No verdict on the link, which is the reading the block already answers by
|
|
26
|
+
* showing the form.
|
|
27
|
+
*/
|
|
28
|
+
const UNCHECKED: ResetTokenValidation = {
|
|
29
|
+
data: undefined,
|
|
30
|
+
isLoading: false,
|
|
31
|
+
isError: false
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a core without the query answers.
|
|
36
|
+
*
|
|
37
|
+
* A hook in its own right — same call signature, same position — so the block
|
|
38
|
+
* calls exactly one hook here on every render either way, and the degradation
|
|
39
|
+
* is the screen brands had before the pre-check existed: the two boxes are
|
|
40
|
+
* offered, and a link that has expired is refused by the SUBMIT instead of
|
|
41
|
+
* before it, through the error path that already handles a refusal.
|
|
42
|
+
*/
|
|
43
|
+
const useUncheckedResetToken: ValidateResetTokenHook = () => UNCHECKED;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read off the module object instead of imported by name.
|
|
47
|
+
*
|
|
48
|
+
* `useGetValidatePasswordResetTokenQuery` arrived with @akinon/next 2.x, and
|
|
49
|
+
* most brands are still on the 1.x line. A brand transpiles and type-checks
|
|
50
|
+
* this package from `src`, so a named import of an export its core does not
|
|
51
|
+
* have fails `next build` before the block can ever run — while a bare runtime
|
|
52
|
+
* lookup would leave `undefined` in a hook position and crash the screen.
|
|
53
|
+
*
|
|
54
|
+
* Both are answered here: the name never appears in an import clause, so the
|
|
55
|
+
* v1 type-checker has nothing to miss, and the fallback above is bound in its
|
|
56
|
+
* place once, at module load, rather than chosen per render.
|
|
57
|
+
*/
|
|
58
|
+
const providedHook = (accountData as unknown as Record<string, unknown>)
|
|
59
|
+
.useGetValidatePasswordResetTokenQuery as ValidateResetTokenHook | undefined;
|
|
60
|
+
|
|
61
|
+
/** Whether the core in use can check a reset link before the shopper types. */
|
|
62
|
+
export const canValidateResetToken = typeof providedHook === 'function';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The core's query where there is one, the fallback where there is not. Bound
|
|
66
|
+
* at module load: the choice is made before any render, never during one.
|
|
67
|
+
*/
|
|
68
|
+
export const useValidateResetToken: ValidateResetTokenHook =
|
|
69
|
+
providedHook ?? useUncheckedResetToken;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ThemeFontsLink, THEME_FONTS_CSS_PATH } from '../theme-fonts-link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The stylesheet a theme's uploaded faces are served from.
|
|
5
|
+
*
|
|
6
|
+
* `precedence` is what makes React hoist this link into the head and keep one
|
|
7
|
+
* copy of it across placeholders, and it is spread rather than written as a
|
|
8
|
+
* JSX attribute because @types/react 18 — what brands on the 1.x line
|
|
9
|
+
* type-check against — does not declare it. The element is read here rather
|
|
10
|
+
* than rendered, so the case pins the prop itself and not React's hoisting.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const element = (settings: Record<string, unknown> | null) =>
|
|
14
|
+
ThemeFontsLink({ settings }) as React.ReactElement | null;
|
|
15
|
+
|
|
16
|
+
describe('the theme fonts stylesheet link', () => {
|
|
17
|
+
it('carries the precedence that hoists and dedupes it', () => {
|
|
18
|
+
const link = element({ customFonts: [{ family: 'Whitney' }] });
|
|
19
|
+
|
|
20
|
+
expect(link).not.toBeNull();
|
|
21
|
+
expect(link!.props).toEqual(
|
|
22
|
+
expect.objectContaining({
|
|
23
|
+
rel: 'stylesheet',
|
|
24
|
+
href: THEME_FONTS_CSS_PATH,
|
|
25
|
+
'data-pz-theme-fonts': 'true',
|
|
26
|
+
precedence: 'pz-theme-fonts'
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is not rendered at all when the theme has uploaded no faces', () => {
|
|
32
|
+
expect(element({ customFonts: [] })).toBeNull();
|
|
33
|
+
expect(element({})).toBeNull();
|
|
34
|
+
expect(element(null)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -16,6 +16,17 @@ import * as React from 'react';
|
|
|
16
16
|
*/
|
|
17
17
|
export const THEME_FONTS_CSS_PATH = '/api/theme-fonts';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Spread onto the link rather than written as a JSX attribute. Brands
|
|
21
|
+
* transpile this file from `src` and type-check it against their own
|
|
22
|
+
* @types/react; 18.x does not declare `precedence`, so `next build` would fail
|
|
23
|
+
* on a prop the renderer itself accepts. The rendered element is identical.
|
|
24
|
+
* Same reason, same shape, as `theme-google-fonts-links.tsx`.
|
|
25
|
+
*/
|
|
26
|
+
const HOIST_AS_RESOURCE: Record<string, string> = {
|
|
27
|
+
precedence: 'pz-theme-fonts'
|
|
28
|
+
};
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Rendered by every placeholder; React keeps ONE of them, matched by `href`,
|
|
21
32
|
* and hoists it into the head.
|
|
@@ -38,7 +49,7 @@ export const ThemeFontsLink = ({
|
|
|
38
49
|
// Read by the client font effect, which stands down when the document
|
|
39
50
|
// already links the faces.
|
|
40
51
|
data-pz-theme-fonts="true"
|
|
41
|
-
|
|
52
|
+
{...HOIST_AS_RESOURCE}
|
|
42
53
|
/>
|
|
43
54
|
);
|
|
44
55
|
};
|