@akinon/pz-theme 2.0.106-beta.0 → 2.0.106

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.106-beta.0
3
+ ## 2.0.106
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.106-beta.0
9
- - @akinon/theme-contract@2.0.106-beta.0
7
+ - e083ee2: ZERO-5078: Keep pz-theme buildable on v1 brands (Next 14.2, React 18 types, @akinon/next 1.x)
8
+ - @akinon/next@2.0.106
9
+ - @akinon/theme-contract@2.0.106
10
10
 
11
11
  ## 2.0.105
12
12
 
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.106-beta.0",
4
+ "version": "2.0.106",
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.106-beta.0",
11
+ "@akinon/next": "2.0.106",
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.106-beta.0",
16
+ "@akinon/theme-contract": "2.0.106",
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.106-beta.0",
24
+ "@akinon/next": "2.0.106",
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: () => validation
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
- headerWrapperClassName="border-0"
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
- usePasswordResetMutation,
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
- } = useGetValidatePasswordResetTokenQuery(token, {
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
- precedence="pz-theme-fonts"
52
+ {...HOIST_AS_RESOURCE}
42
53
  />
43
54
  );
44
55
  };