@akinon/next 2.0.6-rc.0 → 2.0.6-rc.2
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 +16 -0
- package/api/auth.ts +84 -24
- package/api/client.ts +32 -0
- package/components/client-root.tsx +2 -0
- package/components/plugin-module.tsx +8 -3
- package/components/toast.tsx +258 -0
- package/data/client/checkout.ts +0 -1
- package/eslint.config.base.mjs +54 -0
- package/hooks/index.ts +1 -0
- package/hooks/use-toast.ts +56 -0
- package/instrumentation/index.ts +1 -0
- package/instrumentation/node.ts +224 -2
- package/lib/fixture-manager.ts +146 -0
- package/package.json +8 -11
- package/redux/actions.ts +1 -0
- package/redux/middlewares/checkout.ts +19 -3
- package/redux/reducers/index.ts +2 -0
- package/redux/reducers/toast.ts +70 -0
- package/types/commerce/flatpage.ts +7 -0
- package/utils/app-fetch.ts +27 -0
- package/utils/format-error-message.ts +7 -0
- package/utils/index.ts +8 -1
- package/with-pz-config.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
+
## 2.0.6-rc.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 89deabe5: ZERO-4392: Remove deprecated dependencies and update package versions in yarn.lock
|
|
8
|
+
- 1a345c47: ZERO-4234: Add Global Toast Notification System
|
|
9
|
+
- 1f1ae44e: ZERO-4230: Add Commerce API mock/replay mode for development
|
|
10
|
+
- 89ed4e03: ZERO-4394: Add Next.js 16 ESLint migration v9 + flat config
|
|
11
|
+
- 8d8fefbe: ZERO-4398: Enhance SEO metadata generation for flat pages and update FlatPage interface to include localized URLs
|
|
12
|
+
|
|
13
|
+
## 2.0.6-rc.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 51ea0688: ZERO-4377: Fix checkout card type state being cleared after valid bin number responses.
|
|
18
|
+
|
|
3
19
|
## 2.0.6-rc.0
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/api/auth.ts
CHANGED
|
@@ -154,18 +154,49 @@ const getDefaultAuthConfig = () => {
|
|
|
154
154
|
userIp
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
157
|
+
const existingSessionId = cookieStore.get('osessionid')?.value;
|
|
158
|
+
|
|
159
|
+
if (existingSessionId) {
|
|
160
|
+
const checkCurrentUser = await getCurrentUser(
|
|
161
|
+
existingSessionId,
|
|
162
|
+
cookieStore.get('pz-currency')?.value ?? ''
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (checkCurrentUser?.pk) {
|
|
166
|
+
const sessionCookie = reqHeaders
|
|
167
|
+
.get('cookie')
|
|
168
|
+
?.match(/osessionid=\w+/)?.[0]
|
|
169
|
+
.replace(/osessionid=/, '');
|
|
170
|
+
if (sessionCookie) {
|
|
171
|
+
reqHeaders.set('cookie', sessionCookie);
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// Stale session cookie — remove from headers and clear in browser
|
|
175
|
+
const currentCookies = reqHeaders.get('cookie') || '';
|
|
176
|
+
const cleanedCookies = currentCookies
|
|
177
|
+
.split(';')
|
|
178
|
+
.filter((c) => !c.trim().startsWith('osessionid='))
|
|
179
|
+
.join(';')
|
|
180
|
+
.trim();
|
|
181
|
+
reqHeaders.set('cookie', cleanedCookies);
|
|
182
|
+
|
|
183
|
+
const { localeUrlStrategy } = Settings.localization;
|
|
184
|
+
const fallbackHost =
|
|
185
|
+
headerStore.get('x-forwarded-host') ||
|
|
186
|
+
headerStore.get('host');
|
|
187
|
+
const hostname =
|
|
188
|
+
process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`;
|
|
189
|
+
const rootHostname =
|
|
190
|
+
localeUrlStrategy === LocaleUrlStrategy.Subdomain
|
|
191
|
+
? getRootHostname(hostname)
|
|
192
|
+
: null;
|
|
193
|
+
const expireOptions = {
|
|
194
|
+
path: '/',
|
|
195
|
+
maxAge: 0,
|
|
196
|
+
...(rootHostname ? { domain: rootHostname } : {})
|
|
197
|
+
};
|
|
198
|
+
cookieStore.set('osessionid', '', expireOptions);
|
|
199
|
+
cookieStore.set('sessionid', '', expireOptions);
|
|
169
200
|
}
|
|
170
201
|
}
|
|
171
202
|
|
|
@@ -393,18 +424,47 @@ const defaultNextAuthOptionsV4 = (req: any, res: any) => {
|
|
|
393
424
|
userIp
|
|
394
425
|
});
|
|
395
426
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
427
|
+
if (req.cookies['osessionid']) {
|
|
428
|
+
const checkCurrentUser = await getCurrentUser(
|
|
429
|
+
req.cookies['osessionid'],
|
|
430
|
+
req.cookies['pz-currency'] ?? ''
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
if (checkCurrentUser?.pk) {
|
|
434
|
+
const sessionCookie = reqHeaders
|
|
435
|
+
.get('cookie')
|
|
436
|
+
?.match(/osessionid=\w+/)?.[0]
|
|
437
|
+
.replace(/osessionid=/, '');
|
|
438
|
+
if (sessionCookie) {
|
|
439
|
+
reqHeaders.set('cookie', sessionCookie);
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
// Stale session cookie — remove from headers and clear in browser
|
|
443
|
+
const currentCookies = reqHeaders.get('cookie') || '';
|
|
444
|
+
const cleanedCookies = currentCookies
|
|
445
|
+
.split(';')
|
|
446
|
+
.filter((c) => !c.trim().startsWith('osessionid='))
|
|
447
|
+
.join(';')
|
|
448
|
+
.trim();
|
|
449
|
+
reqHeaders.set('cookie', cleanedCookies);
|
|
450
|
+
|
|
451
|
+
const { localeUrlStrategy } = Settings.localization;
|
|
452
|
+
const fallbackHost =
|
|
453
|
+
req.headers['x-forwarded-host']?.toString() ||
|
|
454
|
+
req.headers.host?.toString();
|
|
455
|
+
const hostname =
|
|
456
|
+
process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`;
|
|
457
|
+
const rootHostname =
|
|
458
|
+
localeUrlStrategy === LocaleUrlStrategy.Subdomain
|
|
459
|
+
? getRootHostname(hostname)
|
|
460
|
+
: null;
|
|
461
|
+
const domainOption = rootHostname
|
|
462
|
+
? ` Domain=${rootHostname};`
|
|
463
|
+
: '';
|
|
464
|
+
res.setHeader('Set-Cookie', [
|
|
465
|
+
`osessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;${domainOption}`,
|
|
466
|
+
`sessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;${domainOption}`
|
|
467
|
+
]);
|
|
408
468
|
}
|
|
409
469
|
}
|
|
410
470
|
|
package/api/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import cookieParser from 'set-cookie-parser';
|
|
|
7
7
|
import { cookies } from 'next/headers';
|
|
8
8
|
import getRootHostname from '../utils/get-root-hostname';
|
|
9
9
|
import { LocaleUrlStrategy } from '../localization';
|
|
10
|
+
import { fixtureManager, MockMode } from '../lib/fixture-manager';
|
|
10
11
|
|
|
11
12
|
interface RouteParams {
|
|
12
13
|
params: {
|
|
@@ -156,6 +157,28 @@ async function proxyRequest(...args) {
|
|
|
156
157
|
url += `?${urlSearchParams.toString()}`;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
const mockMode = process.env.PZ_MOCK;
|
|
161
|
+
const fixtureBody = req.method !== 'GET' ? fetchOptions.body : undefined;
|
|
162
|
+
|
|
163
|
+
// Replay mode: serve from fixtures
|
|
164
|
+
if (mockMode === MockMode.REPLAY) {
|
|
165
|
+
const { found, fixture } = await fixtureManager.read(req.method, slug, fixtureBody);
|
|
166
|
+
|
|
167
|
+
if (found) {
|
|
168
|
+
return NextResponse.json(
|
|
169
|
+
options.responseType === 'text'
|
|
170
|
+
? { result: fixture.response.body }
|
|
171
|
+
: fixture.response.body,
|
|
172
|
+
{ status: fixture.response.status }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return NextResponse.json(
|
|
177
|
+
{ error: 'No fixture recorded for this endpoint' },
|
|
178
|
+
{ status: 404 }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
try {
|
|
160
183
|
const request = await fetch(url, fetchOptions);
|
|
161
184
|
|
|
@@ -181,6 +204,15 @@ async function proxyRequest(...args) {
|
|
|
181
204
|
);
|
|
182
205
|
}
|
|
183
206
|
|
|
207
|
+
// Record mode: save response to fixtures
|
|
208
|
+
if (mockMode === MockMode.RECORD) {
|
|
209
|
+
await fixtureManager.write(req.method, slug, fixtureBody, {
|
|
210
|
+
status: request.status,
|
|
211
|
+
headers: fixtureManager.extractHeaders(request.headers),
|
|
212
|
+
body: response
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
184
216
|
const setCookieHeaders = request.headers.getSetCookie();
|
|
185
217
|
const exceptCookieKeys = ['pz-locale', 'pz-currency'];
|
|
186
218
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '../redux/reducers/widget';
|
|
18
18
|
import { LoggerPopup } from './logger-popup';
|
|
19
19
|
import { LoggerProvider } from '../hooks/use-logger-context';
|
|
20
|
+
import { ToastContainer } from './toast';
|
|
20
21
|
import * as Sentry from '@sentry/nextjs';
|
|
21
22
|
import { initSentry } from '../sentry';
|
|
22
23
|
|
|
@@ -150,6 +151,7 @@ export default function ClientRoot({
|
|
|
150
151
|
<LoggerProvider>
|
|
151
152
|
{children}
|
|
152
153
|
<LoggerPopup />
|
|
154
|
+
<ToastContainer />
|
|
153
155
|
</LoggerProvider>
|
|
154
156
|
);
|
|
155
157
|
}
|
|
@@ -27,7 +27,8 @@ enum Plugin {
|
|
|
27
27
|
MasterpassRest = 'pz-masterpass-rest',
|
|
28
28
|
SimilarProducts = 'pz-similar-products',
|
|
29
29
|
Haso = 'pz-haso',
|
|
30
|
-
GooglePay = 'pz-google-pay'
|
|
30
|
+
GooglePay = 'pz-google-pay',
|
|
31
|
+
ListHoverImage = 'pz-list-hover-image'
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export enum Component {
|
|
@@ -68,7 +69,8 @@ export enum Component {
|
|
|
68
69
|
ImageSearchButton = 'ImageSearchButton',
|
|
69
70
|
HeaderImageSearchFeature = 'HeaderImageSearchFeature',
|
|
70
71
|
HasoPaymentGateway = 'HasoPaymentGateway',
|
|
71
|
-
GooglePay = 'GooglePay'
|
|
72
|
+
GooglePay = 'GooglePay',
|
|
73
|
+
ListHoverImage = 'ListHoverImage'
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
const PluginComponents = new Map([
|
|
@@ -122,7 +124,8 @@ const PluginComponents = new Map([
|
|
|
122
124
|
[Plugin.Hepsipay, [Component.Hepsipay]],
|
|
123
125
|
[Plugin.MasterpassRest, [Component.MasterpassRest]],
|
|
124
126
|
[Plugin.Haso, [Component.HasoPaymentGateway]],
|
|
125
|
-
[Plugin.GooglePay, [Component.GooglePay]]
|
|
127
|
+
[Plugin.GooglePay, [Component.GooglePay]],
|
|
128
|
+
[Plugin.ListHoverImage, [Component.ListHoverImage]]
|
|
126
129
|
]);
|
|
127
130
|
|
|
128
131
|
const getPlugin = (component: Component) => {
|
|
@@ -201,6 +204,8 @@ export default function PluginModule({
|
|
|
201
204
|
promise = import(`${'@akinon/pz-haso'}`);
|
|
202
205
|
} else if (plugin === Plugin.GooglePay) {
|
|
203
206
|
promise = import(`${'@akinon/pz-google-pay'}`);
|
|
207
|
+
} else if (plugin === Plugin.ListHoverImage) {
|
|
208
|
+
promise = import(`${'@akinon/pz-list-hover-image'}`);
|
|
204
209
|
}
|
|
205
210
|
} catch (error) {
|
|
206
211
|
logger.error(error);
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
5
|
+
import { twMerge } from 'tailwind-merge'
|
|
6
|
+
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
|
7
|
+
import {
|
|
8
|
+
removeToast,
|
|
9
|
+
Toast as ToastType,
|
|
10
|
+
ToastPosition
|
|
11
|
+
} from '../redux/reducers/toast'
|
|
12
|
+
import { Icon } from './icon'
|
|
13
|
+
|
|
14
|
+
type TypeStyles = {
|
|
15
|
+
container?: string
|
|
16
|
+
icon?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToastClasses {
|
|
20
|
+
root?: string
|
|
21
|
+
item?: string
|
|
22
|
+
icon?: string
|
|
23
|
+
message?: string
|
|
24
|
+
action?: string
|
|
25
|
+
dismiss?: string
|
|
26
|
+
animateIn?: string
|
|
27
|
+
animateOut?: string
|
|
28
|
+
types?: Partial<Record<ToastType['type'], TypeStyles>>
|
|
29
|
+
positions?: Partial<Record<ToastPosition, string>>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToastContainerProps {
|
|
33
|
+
classes?: ToastClasses
|
|
34
|
+
icons?: Partial<Record<ToastType['type'], string>>
|
|
35
|
+
customRender?: (props: {
|
|
36
|
+
toast: ToastType
|
|
37
|
+
onDismiss: () => void
|
|
38
|
+
}) => React.ReactNode
|
|
39
|
+
onAction?: (actionId: string, toast: ToastType) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const builtInTypeStyles: Record<ToastType['type'], TypeStyles> = {
|
|
43
|
+
success: {
|
|
44
|
+
container: 'border-l-4 border-l-success bg-white',
|
|
45
|
+
icon: 'text-success'
|
|
46
|
+
},
|
|
47
|
+
error: {
|
|
48
|
+
container: 'border-l-4 border-l-error bg-white',
|
|
49
|
+
icon: 'text-error'
|
|
50
|
+
},
|
|
51
|
+
warning: {
|
|
52
|
+
container: 'border-l-4 border-l-[#e89a0c] bg-white',
|
|
53
|
+
icon: 'text-[#e89a0c]'
|
|
54
|
+
},
|
|
55
|
+
info: {
|
|
56
|
+
container: 'border-l-4 border-l-primary bg-white',
|
|
57
|
+
icon: 'text-primary'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const builtInIcons: Record<ToastType['type'], string> = {
|
|
62
|
+
success: 'check',
|
|
63
|
+
error: 'close',
|
|
64
|
+
warning: 'info',
|
|
65
|
+
info: 'info'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const builtInPositions: Record<ToastPosition, string> = {
|
|
69
|
+
'top-right': 'fixed top-4 right-4',
|
|
70
|
+
'top-left': 'fixed top-4 left-4',
|
|
71
|
+
'top-center': 'fixed top-4 left-1/2 -translate-x-1/2',
|
|
72
|
+
'bottom-right': 'fixed bottom-4 right-4',
|
|
73
|
+
'bottom-left': 'fixed bottom-4 left-4',
|
|
74
|
+
'bottom-center': 'fixed bottom-4 left-1/2 -translate-x-1/2'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ToastItem = React.memo(function ToastItem({
|
|
78
|
+
toast,
|
|
79
|
+
classes,
|
|
80
|
+
icons,
|
|
81
|
+
customRender,
|
|
82
|
+
onAction
|
|
83
|
+
}: {
|
|
84
|
+
toast: ToastType
|
|
85
|
+
classes: ToastClasses
|
|
86
|
+
icons: Record<ToastType['type'], string>
|
|
87
|
+
customRender?: ToastContainerProps['customRender']
|
|
88
|
+
onAction?: ToastContainerProps['onAction']
|
|
89
|
+
}) {
|
|
90
|
+
const dispatch = useAppDispatch()
|
|
91
|
+
const [exiting, setExiting] = useState(false)
|
|
92
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (toast.duration > 0) {
|
|
96
|
+
timerRef.current = setTimeout(() => {
|
|
97
|
+
setExiting(true)
|
|
98
|
+
}, toast.duration)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
103
|
+
}
|
|
104
|
+
}, [toast.duration])
|
|
105
|
+
|
|
106
|
+
const handleAnimationEnd = () => {
|
|
107
|
+
if (exiting) {
|
|
108
|
+
dispatch(removeToast(toast.id))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handleDismiss = () => {
|
|
113
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
114
|
+
setExiting(true)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const animIn = classes.animateIn ?? 'animate-toast-in'
|
|
118
|
+
const animOut = classes.animateOut ?? 'animate-toast-out'
|
|
119
|
+
|
|
120
|
+
if (customRender) {
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className={twMerge(
|
|
124
|
+
'pointer-events-auto',
|
|
125
|
+
exiting ? animOut : animIn
|
|
126
|
+
)}
|
|
127
|
+
onAnimationEnd={handleAnimationEnd}
|
|
128
|
+
>
|
|
129
|
+
{customRender({ toast, onDismiss: handleDismiss })}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const typeStyle = classes.types?.[toast.type] ?? builtInTypeStyles[toast.type]
|
|
135
|
+
const iconName = toast.icon ?? icons[toast.type]
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
role="alert"
|
|
140
|
+
className={twMerge(
|
|
141
|
+
'flex items-center gap-3 px-4 py-3 rounded shadow-lg min-w-[300px] max-w-[420px]',
|
|
142
|
+
'pointer-events-auto',
|
|
143
|
+
exiting ? animOut : animIn,
|
|
144
|
+
typeStyle.container,
|
|
145
|
+
classes.item,
|
|
146
|
+
toast.className
|
|
147
|
+
)}
|
|
148
|
+
onAnimationEnd={handleAnimationEnd}
|
|
149
|
+
>
|
|
150
|
+
<Icon
|
|
151
|
+
name={iconName}
|
|
152
|
+
size={18}
|
|
153
|
+
className={twMerge('shrink-0', typeStyle.icon, classes.icon)}
|
|
154
|
+
/>
|
|
155
|
+
<p
|
|
156
|
+
className={twMerge(
|
|
157
|
+
'flex-1 text-sm text-primary leading-snug',
|
|
158
|
+
classes.message
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{toast.message}
|
|
162
|
+
</p>
|
|
163
|
+
{toast.action && onAction && (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={() => onAction(toast.action.actionId, toast)}
|
|
167
|
+
className={twMerge(
|
|
168
|
+
'shrink-0 text-xs font-semibold text-primary underline hover:no-underline transition-colors',
|
|
169
|
+
classes.action
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{toast.action.label}
|
|
173
|
+
</button>
|
|
174
|
+
)}
|
|
175
|
+
{toast.dismissible !== false && (
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={handleDismiss}
|
|
179
|
+
className={twMerge(
|
|
180
|
+
'shrink-0 p-1 text-gray-600 hover:text-primary transition-colors',
|
|
181
|
+
classes.dismiss
|
|
182
|
+
)}
|
|
183
|
+
aria-label="Dismiss"
|
|
184
|
+
>
|
|
185
|
+
<Icon name="close" size={12} />
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const EMPTY_CLASSES: ToastClasses = {}
|
|
193
|
+
|
|
194
|
+
export function ToastContainer({
|
|
195
|
+
classes = EMPTY_CLASSES,
|
|
196
|
+
icons,
|
|
197
|
+
customRender,
|
|
198
|
+
onAction
|
|
199
|
+
}: ToastContainerProps = {}) {
|
|
200
|
+
const toasts = useAppSelector((state) => state.toast.toasts)
|
|
201
|
+
const [mounted, setMounted] = useState(false)
|
|
202
|
+
|
|
203
|
+
const mergedIcons = useMemo(
|
|
204
|
+
() => ({ ...builtInIcons, ...icons }),
|
|
205
|
+
[icons]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const posStyles = useMemo(
|
|
209
|
+
() => ({ ...builtInPositions, ...classes.positions }),
|
|
210
|
+
[classes.positions]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const grouped = useMemo(
|
|
214
|
+
() =>
|
|
215
|
+
toasts.reduce<Record<string, ToastType[]>>((acc, toast) => {
|
|
216
|
+
const pos = toast.position ?? 'top-right'
|
|
217
|
+
if (!acc[pos]) acc[pos] = []
|
|
218
|
+
acc[pos].push(toast)
|
|
219
|
+
return acc
|
|
220
|
+
}, {}),
|
|
221
|
+
[toasts]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
setMounted(true)
|
|
226
|
+
}, [])
|
|
227
|
+
|
|
228
|
+
if (!mounted || toasts.length === 0) return null
|
|
229
|
+
|
|
230
|
+
return createPortal(
|
|
231
|
+
<>
|
|
232
|
+
{Object.entries(grouped).map(([position, items]) => (
|
|
233
|
+
<div
|
|
234
|
+
key={position}
|
|
235
|
+
aria-live="polite"
|
|
236
|
+
aria-label="Notifications"
|
|
237
|
+
className={twMerge(
|
|
238
|
+
'z-[9999] flex flex-col gap-3 pointer-events-none',
|
|
239
|
+
posStyles[position as ToastPosition],
|
|
240
|
+
classes.root
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{items.map((toast) => (
|
|
244
|
+
<ToastItem
|
|
245
|
+
key={toast.id}
|
|
246
|
+
toast={toast}
|
|
247
|
+
classes={classes}
|
|
248
|
+
icons={mergedIcons}
|
|
249
|
+
customRender={customRender}
|
|
250
|
+
onAction={onAction}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
))}
|
|
255
|
+
</>,
|
|
256
|
+
document.body
|
|
257
|
+
)
|
|
258
|
+
}
|
package/data/client/checkout.ts
CHANGED
|
@@ -738,7 +738,6 @@ export const checkoutApi = api.injectEndpoints({
|
|
|
738
738
|
},
|
|
739
739
|
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
740
740
|
dispatch(setPaymentStepBusy(true));
|
|
741
|
-
dispatch(setCardType(arg));
|
|
742
741
|
await queryFulfilled;
|
|
743
742
|
dispatch(setPaymentStepBusy(false));
|
|
744
743
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
2
|
+
import nextVitals from 'eslint-config-next/core-web-vitals'
|
|
3
|
+
import nextTs from 'eslint-config-next/typescript'
|
|
4
|
+
import prettier from 'eslint-config-prettier/flat'
|
|
5
|
+
import projectzero from '@akinon/eslint-plugin-projectzero'
|
|
6
|
+
|
|
7
|
+
export default defineConfig([
|
|
8
|
+
...nextVitals,
|
|
9
|
+
...nextTs,
|
|
10
|
+
prettier,
|
|
11
|
+
{
|
|
12
|
+
plugins: {
|
|
13
|
+
'@akinon/projectzero': projectzero
|
|
14
|
+
},
|
|
15
|
+
rules: {
|
|
16
|
+
...projectzero.configs.core.rules,
|
|
17
|
+
...projectzero.configs.recommended.rules,
|
|
18
|
+
// typescript-eslint v6 → v8 renamed rules (override legacy `recommended` preset).
|
|
19
|
+
'@typescript-eslint/no-require-imports': 'off',
|
|
20
|
+
'@typescript-eslint/no-empty-object-type': [
|
|
21
|
+
'error',
|
|
22
|
+
{ allowInterfaces: 'never' }
|
|
23
|
+
],
|
|
24
|
+
'@typescript-eslint/no-unused-expressions': 'warn',
|
|
25
|
+
'@typescript-eslint/no-unused-vars': 'warn',
|
|
26
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
27
|
+
// react-hooks v7 new strict rules — softened to warn for incremental adoption.
|
|
28
|
+
'react-hooks/set-state-in-effect': 'warn',
|
|
29
|
+
'react-hooks/static-components': 'warn',
|
|
30
|
+
'react-hooks/error-boundaries': 'warn',
|
|
31
|
+
'react-hooks/preserve-manual-memoization': 'warn',
|
|
32
|
+
'react-hooks/incompatible-library': 'warn',
|
|
33
|
+
'react-hooks/immutability': 'warn'
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
files: ['eslint.config.{js,cjs,mjs}', 'middlewares/default.ts'],
|
|
38
|
+
rules: {
|
|
39
|
+
'@akinon/projectzero/check-middleware-order': 'error'
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
files: ['redux/middlewares/pre-order/index.ts'],
|
|
44
|
+
rules: {
|
|
45
|
+
'@akinon/projectzero/check-pre-order-middleware-order': 'error'
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
globalIgnores([
|
|
49
|
+
'.next/**',
|
|
50
|
+
'out/**',
|
|
51
|
+
'build/**',
|
|
52
|
+
'next-env.d.ts'
|
|
53
|
+
])
|
|
54
|
+
])
|
package/hooks/index.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useAppDispatch } from '../redux/hooks'
|
|
5
|
+
import {
|
|
6
|
+
addToast,
|
|
7
|
+
removeToast,
|
|
8
|
+
ToastType,
|
|
9
|
+
ToastInput
|
|
10
|
+
} from '../redux/reducers/toast'
|
|
11
|
+
|
|
12
|
+
export type ToastOptions = Omit<ToastInput, 'type' | 'message'>
|
|
13
|
+
|
|
14
|
+
export const useToast = () => {
|
|
15
|
+
const dispatch = useAppDispatch()
|
|
16
|
+
|
|
17
|
+
const toast = useCallback(
|
|
18
|
+
(type: ToastType, message: string, options?: ToastOptions) => {
|
|
19
|
+
dispatch(addToast({ type, message, ...options }))
|
|
20
|
+
},
|
|
21
|
+
[dispatch]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const success = useCallback(
|
|
25
|
+
(message: string, options?: ToastOptions) =>
|
|
26
|
+
toast('success', message, options),
|
|
27
|
+
[toast]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const error = useCallback(
|
|
31
|
+
(message: string, options?: ToastOptions) =>
|
|
32
|
+
toast('error', message, options),
|
|
33
|
+
[toast]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const warning = useCallback(
|
|
37
|
+
(message: string, options?: ToastOptions) =>
|
|
38
|
+
toast('warning', message, options),
|
|
39
|
+
[toast]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const info = useCallback(
|
|
43
|
+
(message: string, options?: ToastOptions) =>
|
|
44
|
+
toast('info', message, options),
|
|
45
|
+
[toast]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const dismiss = useCallback(
|
|
49
|
+
(id: string) => {
|
|
50
|
+
dispatch(removeToast(id))
|
|
51
|
+
},
|
|
52
|
+
[dispatch]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return { toast, success, error, warning, info, dismiss }
|
|
56
|
+
}
|
package/instrumentation/index.ts
CHANGED
package/instrumentation/node.ts
CHANGED
|
@@ -1,2 +1,224 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { context, trace, type SpanAttributes } from '@opentelemetry/api';
|
|
2
|
+
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
|
3
|
+
import { ExportResultCode, type ExportResult } from '@opentelemetry/core';
|
|
4
|
+
import {
|
|
5
|
+
BasicTracerProvider,
|
|
6
|
+
SimpleSpanProcessor,
|
|
7
|
+
type ReadableSpan,
|
|
8
|
+
type SpanExporter
|
|
9
|
+
} from '@opentelemetry/sdk-trace-base';
|
|
10
|
+
|
|
11
|
+
type OTelAttributeValue =
|
|
12
|
+
| { stringValue: string }
|
|
13
|
+
| { intValue: number }
|
|
14
|
+
| { doubleValue: number }
|
|
15
|
+
| { boolValue: boolean };
|
|
16
|
+
|
|
17
|
+
const PZ_DASHBOARD_TRACING_STARTED_KEY = '__pzDashboardTracingStarted__';
|
|
18
|
+
const SERVICE_NAME = 'pz-next-app';
|
|
19
|
+
const DASHBOARD_FALLBACK_URL = 'http://localhost:3005';
|
|
20
|
+
|
|
21
|
+
type PzDashboardGlobal = typeof globalThis & {
|
|
22
|
+
[PZ_DASHBOARD_TRACING_STARTED_KEY]?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const getDashboardTraceUrl = () => {
|
|
26
|
+
const baseUrl = process.env.PZ_DASHBOARD_URL ?? DASHBOARD_FALLBACK_URL;
|
|
27
|
+
|
|
28
|
+
return `${baseUrl.replace(/\/$/, '')}/api/traces`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hrTimeToUnixNano = ([seconds, nanos]: [number, number]) =>
|
|
32
|
+
(BigInt(seconds) * BigInt(1000000000) + BigInt(nanos)).toString();
|
|
33
|
+
|
|
34
|
+
const getAttributeValue = (value: unknown): OTelAttributeValue => {
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
return { stringValue: value };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
return Number.isInteger(value)
|
|
41
|
+
? { intValue: value }
|
|
42
|
+
: { doubleValue: value };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof value === 'boolean') {
|
|
46
|
+
return { boolValue: value };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { stringValue: JSON.stringify(value ?? '') };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const getAttributes = (attributes: SpanAttributes = {}) =>
|
|
53
|
+
Object.entries(attributes)
|
|
54
|
+
.filter(([, value]) => value !== undefined)
|
|
55
|
+
.map(([key, value]) => ({
|
|
56
|
+
key,
|
|
57
|
+
value: getAttributeValue(value)
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const getParentSpanId = (span: ReadableSpan) => {
|
|
61
|
+
if ('parentSpanId' in span && typeof span.parentSpanId === 'string') {
|
|
62
|
+
return span.parentSpanId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
'parentSpanContext' in span &&
|
|
67
|
+
span.parentSpanContext &&
|
|
68
|
+
typeof span.parentSpanContext === 'object' &&
|
|
69
|
+
'spanId' in span.parentSpanContext
|
|
70
|
+
) {
|
|
71
|
+
return String(span.parentSpanContext.spanId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const getResourceAttributes = (span: ReadableSpan) => ({
|
|
78
|
+
...(span.resource?.attributes ?? {}),
|
|
79
|
+
'service.name':
|
|
80
|
+
process.env.OTEL_SERVICE_NAME ??
|
|
81
|
+
process.env.NEXT_PUBLIC_APP_NAME ??
|
|
82
|
+
SERVICE_NAME
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const getScope = (span: ReadableSpan) => {
|
|
86
|
+
const scopedSpan = span as ReadableSpan & {
|
|
87
|
+
instrumentationLibrary?: {
|
|
88
|
+
name?: string;
|
|
89
|
+
version?: string;
|
|
90
|
+
};
|
|
91
|
+
instrumentationScope?: {
|
|
92
|
+
name?: string;
|
|
93
|
+
version?: string;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const scope =
|
|
97
|
+
scopedSpan.instrumentationScope ?? scopedSpan.instrumentationLibrary;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
name: scope?.name ?? SERVICE_NAME,
|
|
101
|
+
version: scope?.version ?? ''
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const getSpanPayload = (span: ReadableSpan) => ({
|
|
106
|
+
traceId: span.spanContext().traceId,
|
|
107
|
+
spanId: span.spanContext().spanId,
|
|
108
|
+
parentSpanId: getParentSpanId(span),
|
|
109
|
+
name: span.name,
|
|
110
|
+
kind: span.kind,
|
|
111
|
+
startTimeUnixNano: hrTimeToUnixNano(span.startTime),
|
|
112
|
+
endTimeUnixNano: hrTimeToUnixNano(span.endTime),
|
|
113
|
+
attributes: getAttributes(span.attributes),
|
|
114
|
+
droppedAttributesCount: span.droppedAttributesCount ?? 0,
|
|
115
|
+
events:
|
|
116
|
+
span.events?.map((event) => ({
|
|
117
|
+
name: event.name,
|
|
118
|
+
timeUnixNano: hrTimeToUnixNano(event.time),
|
|
119
|
+
attributes: getAttributes(event.attributes),
|
|
120
|
+
droppedAttributesCount: event.droppedAttributesCount ?? 0
|
|
121
|
+
})) ?? [],
|
|
122
|
+
droppedEventsCount: span.droppedEventsCount ?? 0,
|
|
123
|
+
status: span.status,
|
|
124
|
+
links:
|
|
125
|
+
span.links?.map((link) => ({
|
|
126
|
+
traceId: link.context.traceId,
|
|
127
|
+
spanId: link.context.spanId,
|
|
128
|
+
attributes: getAttributes(link.attributes),
|
|
129
|
+
droppedAttributesCount: link.droppedAttributesCount ?? 0
|
|
130
|
+
})) ?? [],
|
|
131
|
+
droppedLinksCount: span.droppedLinksCount ?? 0
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
class PzDashboardTraceExporter implements SpanExporter {
|
|
135
|
+
constructor(private readonly url: string) {}
|
|
136
|
+
|
|
137
|
+
export(
|
|
138
|
+
spans: ReadableSpan[],
|
|
139
|
+
resultCallback: (result: ExportResult) => void
|
|
140
|
+
) {
|
|
141
|
+
const [firstSpan] = spans;
|
|
142
|
+
|
|
143
|
+
if (!firstSpan) {
|
|
144
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
void this.sendSpans(spans, firstSpan, resultCallback);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async sendSpans(
|
|
152
|
+
spans: ReadableSpan[],
|
|
153
|
+
firstSpan: ReadableSpan,
|
|
154
|
+
resultCallback: (result: ExportResult) => void
|
|
155
|
+
) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(this.url, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
resourceSpans: [
|
|
162
|
+
{
|
|
163
|
+
resource: {
|
|
164
|
+
attributes: getAttributes(getResourceAttributes(firstSpan)),
|
|
165
|
+
droppedAttributesCount: 0
|
|
166
|
+
},
|
|
167
|
+
scopeSpans: spans.map((span) => ({
|
|
168
|
+
scope: getScope(span),
|
|
169
|
+
spans: [getSpanPayload(span)]
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
})
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
throw new Error(`PZ Dashboard trace export failed: ${response.status}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
resultCallback({
|
|
183
|
+
code: ExportResultCode.FAILED,
|
|
184
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
shutdown() {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const startDashboardTracing = () => {
|
|
195
|
+
const pzDashboardGlobal = globalThis as PzDashboardGlobal;
|
|
196
|
+
|
|
197
|
+
if (pzDashboardGlobal[PZ_DASHBOARD_TRACING_STARTED_KEY]) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const provider = new BasicTracerProvider({
|
|
202
|
+
spanProcessors: [
|
|
203
|
+
new SimpleSpanProcessor(
|
|
204
|
+
new PzDashboardTraceExporter(getDashboardTraceUrl())
|
|
205
|
+
)
|
|
206
|
+
]
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
context.setGlobalContextManager(
|
|
210
|
+
new AsyncLocalStorageContextManager().enable()
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const registered = trace.setGlobalTracerProvider(provider);
|
|
214
|
+
|
|
215
|
+
if (!registered) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pzDashboardGlobal[PZ_DASHBOARD_TRACING_STARTED_KEY] = true;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (process.env.NODE_ENV === 'development') {
|
|
223
|
+
startDashboardTracing();
|
|
224
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import * as crypto from 'crypto'
|
|
4
|
+
import logger from '../utils/log'
|
|
5
|
+
|
|
6
|
+
export const MockMode = {
|
|
7
|
+
RECORD: 'record',
|
|
8
|
+
REPLAY: 'replay'
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
export type MockMode = (typeof MockMode)[keyof typeof MockMode]
|
|
12
|
+
|
|
13
|
+
interface FixtureData {
|
|
14
|
+
request: {
|
|
15
|
+
method: string
|
|
16
|
+
url: string
|
|
17
|
+
body?: any
|
|
18
|
+
}
|
|
19
|
+
response: {
|
|
20
|
+
status: number
|
|
21
|
+
headers: Record<string, string>
|
|
22
|
+
body: any
|
|
23
|
+
}
|
|
24
|
+
recorded_at: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SENSITIVE_HEADERS = [
|
|
28
|
+
'authorization',
|
|
29
|
+
'cookie',
|
|
30
|
+
'set-cookie',
|
|
31
|
+
'x-csrftoken',
|
|
32
|
+
'x-forwarded-for'
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
class FixtureManager {
|
|
36
|
+
private fixturesDir: string
|
|
37
|
+
private dirEnsured = false
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
this.fixturesDir = path.join(process.cwd(), '.pz-fixtures')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private ensureDir(): void {
|
|
44
|
+
if (this.dirEnsured) return
|
|
45
|
+
fs.mkdirSync(this.fixturesDir, { recursive: true })
|
|
46
|
+
this.dirEnsured = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
generateKey(method: string, url: string, body?: any): string {
|
|
50
|
+
const normalized = method?.toUpperCase() ?? 'GET'
|
|
51
|
+
const parts = [normalized, url]
|
|
52
|
+
|
|
53
|
+
if (body && normalized !== 'GET') {
|
|
54
|
+
const bodyStr =
|
|
55
|
+
typeof body === 'string' ? body : JSON.stringify(body)
|
|
56
|
+
parts.push(bodyStr)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return crypto.createHash('md5').update(parts.join(':')).digest('hex')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
extractHeaders(headers: Headers): Record<string, string> {
|
|
63
|
+
const result: Record<string, string> = {}
|
|
64
|
+
headers.forEach((value, key) => {
|
|
65
|
+
result[key] = value
|
|
66
|
+
})
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private stripSensitiveHeaders(
|
|
71
|
+
headers: Record<string, string>
|
|
72
|
+
): Record<string, string> {
|
|
73
|
+
const cleaned: Record<string, string> = {}
|
|
74
|
+
|
|
75
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
76
|
+
if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) {
|
|
77
|
+
cleaned[key] = value
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return cleaned
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async write(
|
|
85
|
+
method: string,
|
|
86
|
+
url: string,
|
|
87
|
+
body: any,
|
|
88
|
+
response: { status: number; headers: Record<string, string>; body: any }
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
this.ensureDir()
|
|
92
|
+
|
|
93
|
+
const normalized = method?.toUpperCase() ?? 'GET'
|
|
94
|
+
const key = this.generateKey(normalized, url, body)
|
|
95
|
+
const fixture: FixtureData = {
|
|
96
|
+
request: {
|
|
97
|
+
method: normalized,
|
|
98
|
+
url
|
|
99
|
+
},
|
|
100
|
+
response: {
|
|
101
|
+
status: response.status,
|
|
102
|
+
headers: this.stripSensitiveHeaders(response.headers),
|
|
103
|
+
body: response.body
|
|
104
|
+
},
|
|
105
|
+
recorded_at: new Date().toISOString()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (body && normalized !== 'GET') {
|
|
109
|
+
fixture.request.body = body
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const filePath = path.join(this.fixturesDir, `${key}.json`)
|
|
113
|
+
await fs.promises.writeFile(filePath, JSON.stringify(fixture, null, 2))
|
|
114
|
+
|
|
115
|
+
logger.debug(`[pz-mock] Recorded fixture: ${normalized} ${url} → ${filePath}`)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error(`[pz-mock] Failed to write fixture`, { url, error })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async read(
|
|
122
|
+
method: string,
|
|
123
|
+
url: string,
|
|
124
|
+
body?: any
|
|
125
|
+
): Promise<{ found: boolean; fixture?: FixtureData }> {
|
|
126
|
+
try {
|
|
127
|
+
const key = this.generateKey(method, url, body)
|
|
128
|
+
const filePath = path.join(this.fixturesDir, `${key}.json`)
|
|
129
|
+
|
|
130
|
+
const raw = await fs.promises.readFile(filePath, 'utf-8')
|
|
131
|
+
const fixture = JSON.parse(raw) as FixtureData
|
|
132
|
+
|
|
133
|
+
logger.debug(`[pz-mock] Replaying fixture: ${method} ${url} → ${key}`)
|
|
134
|
+
return { found: true, fixture }
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
137
|
+
logger.warn(`[pz-mock] No fixture found: ${method} ${url}`)
|
|
138
|
+
return { found: false }
|
|
139
|
+
}
|
|
140
|
+
logger.error(`[pz-mock] Failed to read fixture`, { url, error })
|
|
141
|
+
return { found: false }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const fixtureManager = new FixtureManager()
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/next",
|
|
3
3
|
"description": "Core package for Project Zero Next",
|
|
4
|
-
"version": "2.0.6-rc.
|
|
4
|
+
"version": "2.0.6-rc.2",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -19,11 +19,10 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@mongodb-js/zstd": "^2.0.1",
|
|
21
21
|
"@neshca/cache-handler": "1.9.0",
|
|
22
|
-
"@opentelemetry/
|
|
23
|
-
"@opentelemetry/
|
|
24
|
-
"@opentelemetry/
|
|
25
|
-
"@opentelemetry/sdk-trace-
|
|
26
|
-
"@opentelemetry/semantic-conventions": "1.19.0",
|
|
22
|
+
"@opentelemetry/api": "^1.9.0",
|
|
23
|
+
"@opentelemetry/context-async-hooks": "^2.5.0",
|
|
24
|
+
"@opentelemetry/core": "^2.5.0",
|
|
25
|
+
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
|
27
26
|
"@reduxjs/toolkit": "1.9.7",
|
|
28
27
|
"@sentry/nextjs": "10.39.0",
|
|
29
28
|
"cross-spawn": "7.0.3",
|
|
@@ -37,19 +36,17 @@
|
|
|
37
36
|
"set-cookie-parser": "2.6.0"
|
|
38
37
|
},
|
|
39
38
|
"devDependencies": {
|
|
40
|
-
"@akinon/eslint-plugin-projectzero": "2.0.6-rc.
|
|
39
|
+
"@akinon/eslint-plugin-projectzero": "2.0.6-rc.2",
|
|
41
40
|
"@babel/core": "7.26.10",
|
|
42
41
|
"@babel/preset-env": "7.26.9",
|
|
43
42
|
"@babel/preset-typescript": "7.27.0",
|
|
44
43
|
"@types/jest": "29.5.14",
|
|
45
44
|
"@types/react-redux": "7.1.30",
|
|
46
45
|
"@types/set-cookie-parser": "2.4.7",
|
|
47
|
-
"@typescript-eslint/eslint-plugin": "6.7.4",
|
|
48
|
-
"@typescript-eslint/parser": "6.7.4",
|
|
49
46
|
"babel-jest": "29.7.0",
|
|
50
|
-
"eslint": "
|
|
47
|
+
"eslint": "9.39.4",
|
|
51
48
|
"eslint-config-next": "16.2.4",
|
|
52
|
-
"eslint-config-prettier": "
|
|
49
|
+
"eslint-config-prettier": "10.1.1",
|
|
53
50
|
"jest": "29.7.0",
|
|
54
51
|
"ts-jest": "29.3.2",
|
|
55
52
|
"typescript": "5.9.3"
|
package/redux/actions.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '../../redux/reducers/checkout';
|
|
27
27
|
import { RootState, TypedDispatch } from 'redux/store';
|
|
28
28
|
import { checkoutApi } from '../../data/client/checkout';
|
|
29
|
-
import { CheckoutContext, PreOrder } from '../../types';
|
|
29
|
+
import { CheckoutContext, MiddlewareAction, PreOrder } from '../../types';
|
|
30
30
|
import { getCookie } from '../../utils';
|
|
31
31
|
import settings from 'settings';
|
|
32
32
|
import { LocaleUrlStrategy } from '../../localization';
|
|
@@ -114,8 +114,15 @@ export const contextListMiddleware: Middleware = ({
|
|
|
114
114
|
const { isMobileApp, userPhoneNumber } = getState().root;
|
|
115
115
|
const result = next(action) as CheckoutResult;
|
|
116
116
|
const preOrder = result?.payload?.pre_order;
|
|
117
|
+
const act = action as MiddlewareAction;
|
|
117
118
|
|
|
118
119
|
if (result?.payload?.context_list) {
|
|
120
|
+
const endpointName = act.meta?.arg?.endpointName;
|
|
121
|
+
const isBinNumberResponse = endpointName === 'setBinNumber';
|
|
122
|
+
const hasCardTypeInContextList = result.payload.context_list.some(
|
|
123
|
+
(ctx) => ctx.page_context.card_type
|
|
124
|
+
);
|
|
125
|
+
|
|
119
126
|
result.payload.context_list.forEach((context) => {
|
|
120
127
|
const redirectUrl = context.page_context.redirect_url;
|
|
121
128
|
const isIframe = context.page_context.is_iframe ?? false;
|
|
@@ -231,15 +238,24 @@ export const contextListMiddleware: Middleware = ({
|
|
|
231
238
|
|
|
232
239
|
if (context.page_context.card_type) {
|
|
233
240
|
dispatch(setCardType(context.page_context.card_type));
|
|
234
|
-
} else if (
|
|
241
|
+
} else if (
|
|
242
|
+
isCreditCardPayment &&
|
|
243
|
+
isBinNumberResponse &&
|
|
244
|
+
!hasCardTypeInContextList
|
|
245
|
+
) {
|
|
235
246
|
dispatch(setCardType(null));
|
|
247
|
+
dispatch(setInstallmentOptions([]));
|
|
236
248
|
}
|
|
237
249
|
|
|
238
250
|
if (
|
|
239
251
|
context.page_context.installments &&
|
|
240
252
|
preOrder?.payment_option?.payment_type !== 'masterpass_rest'
|
|
241
253
|
) {
|
|
242
|
-
if (
|
|
254
|
+
if (
|
|
255
|
+
!isCreditCardPayment ||
|
|
256
|
+
context.page_context.card_type ||
|
|
257
|
+
hasCardTypeInContextList
|
|
258
|
+
) {
|
|
243
259
|
dispatch(
|
|
244
260
|
setInstallmentOptions(context.page_context.installments)
|
|
245
261
|
);
|
package/redux/reducers/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import rootReducer from './root';
|
|
|
2
2
|
import checkoutReducer from './checkout';
|
|
3
3
|
import configReducer from './config';
|
|
4
4
|
import headerReducer from './header';
|
|
5
|
+
import toastReducer from './toast';
|
|
5
6
|
import widgetReducer from './widget';
|
|
6
7
|
import { api } from '../../data/client/api';
|
|
7
8
|
|
|
@@ -20,6 +21,7 @@ const reducers = {
|
|
|
20
21
|
checkout: checkoutReducer,
|
|
21
22
|
config: configReducer,
|
|
22
23
|
header: headerReducer,
|
|
24
|
+
toast: toastReducer,
|
|
23
25
|
widget: widgetReducer,
|
|
24
26
|
masterpass: masterpassReducer || fallbackReducer,
|
|
25
27
|
otp: otpReducer || fallbackReducer,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
|
2
|
+
|
|
3
|
+
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
|
4
|
+
|
|
5
|
+
export type ToastPosition =
|
|
6
|
+
| 'top-right'
|
|
7
|
+
| 'top-left'
|
|
8
|
+
| 'top-center'
|
|
9
|
+
| 'bottom-right'
|
|
10
|
+
| 'bottom-left'
|
|
11
|
+
| 'bottom-center'
|
|
12
|
+
|
|
13
|
+
export interface Toast {
|
|
14
|
+
id: string
|
|
15
|
+
type: ToastType
|
|
16
|
+
message: string
|
|
17
|
+
duration?: number
|
|
18
|
+
position?: ToastPosition
|
|
19
|
+
icon?: string
|
|
20
|
+
className?: string
|
|
21
|
+
dismissible?: boolean
|
|
22
|
+
action?: {
|
|
23
|
+
label: string
|
|
24
|
+
actionId: string
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ToastInput = Omit<Toast, 'id'>
|
|
29
|
+
|
|
30
|
+
export interface ToastState {
|
|
31
|
+
toasts: Toast[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_TOASTS = 5
|
|
35
|
+
|
|
36
|
+
const initialState: ToastState = {
|
|
37
|
+
toasts: []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toastSlice = createSlice({
|
|
41
|
+
name: 'toast',
|
|
42
|
+
initialState,
|
|
43
|
+
reducers: {
|
|
44
|
+
addToast: {
|
|
45
|
+
reducer: (state, action: PayloadAction<Toast>) => {
|
|
46
|
+
state.toasts.push(action.payload)
|
|
47
|
+
if (state.toasts.length > MAX_TOASTS) {
|
|
48
|
+
state.toasts.shift()
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
prepare: (toast: ToastInput) => ({
|
|
52
|
+
payload: {
|
|
53
|
+
...toast,
|
|
54
|
+
id: `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
55
|
+
duration: toast.duration ?? 4000,
|
|
56
|
+
position: toast.position ?? 'top-right',
|
|
57
|
+
dismissible: toast.dismissible ?? true
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
removeToast: (state, action: PayloadAction<string>) => {
|
|
62
|
+
const idx = state.toasts.findIndex((t) => t.id === action.payload)
|
|
63
|
+
if (idx !== -1) state.toasts.splice(idx, 1)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const { addToast, removeToast } = toastSlice.actions
|
|
69
|
+
|
|
70
|
+
export default toastSlice.reducer
|
package/utils/app-fetch.ts
CHANGED
|
@@ -3,6 +3,7 @@ import logger from '../utils/log';
|
|
|
3
3
|
import { headers, cookies } from 'next/headers';
|
|
4
4
|
import { ServerVariables } from './server-variables';
|
|
5
5
|
import { notFound } from 'next/navigation';
|
|
6
|
+
import { fixtureManager, MockMode } from '../lib/fixture-manager';
|
|
6
7
|
|
|
7
8
|
export enum FetchResponseType {
|
|
8
9
|
JSON = 'json',
|
|
@@ -42,6 +43,23 @@ const appFetch = async <T>({
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const requestURL = `${decodeURIComponent(commerceUrl)}${url}`;
|
|
46
|
+
const mockMode = process.env.PZ_MOCK;
|
|
47
|
+
const method = init.method?.toUpperCase() ?? 'GET';
|
|
48
|
+
|
|
49
|
+
// Replay mode: serve from fixtures without hitting the API
|
|
50
|
+
if (mockMode === MockMode.REPLAY) {
|
|
51
|
+
const { found, fixture } = await fixtureManager.read(method, String(url), init.body);
|
|
52
|
+
|
|
53
|
+
if (found) {
|
|
54
|
+
status = fixture.response.status;
|
|
55
|
+
response = (responseType === FetchResponseType.JSON
|
|
56
|
+
? fixture.response.body
|
|
57
|
+
: JSON.stringify(fixture.response.body)) as T;
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
45
63
|
|
|
46
64
|
init.headers = {
|
|
47
65
|
cookie: nextCookies.toString(),
|
|
@@ -67,6 +85,15 @@ const appFetch = async <T>({
|
|
|
67
85
|
response = (await req.text()) as unknown as T;
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
// Record mode: save response to fixtures
|
|
89
|
+
if (mockMode === MockMode.RECORD) {
|
|
90
|
+
await fixtureManager.write(method, String(url), init.body, {
|
|
91
|
+
status: req.status,
|
|
92
|
+
headers: fixtureManager.extractHeaders(req.headers),
|
|
93
|
+
body: response
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
logger.trace(`FETCH RESPONSE`, { url, response, ip });
|
|
71
98
|
} catch (error) {
|
|
72
99
|
const logType = status === 500 ? 'fatal' : 'error';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const formatErrorMessage = (errors: any): string => {
|
|
2
|
+
if (typeof errors === 'string') return errors
|
|
3
|
+
if (Array.isArray(errors)) return errors.join(', ')
|
|
4
|
+
if (typeof errors === 'object' && errors !== null)
|
|
5
|
+
return Object.values(errors).flat().join(', ')
|
|
6
|
+
return 'An error occurred'
|
|
7
|
+
}
|
package/utils/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './generate-commerce-search-params';
|
|
|
9
9
|
export * from './get-currency-label';
|
|
10
10
|
export * from './pz-segments';
|
|
11
11
|
export * from './get-checkout-path';
|
|
12
|
+
export * from './format-error-message';
|
|
12
13
|
|
|
13
14
|
export function getCookie(name: string) {
|
|
14
15
|
if (typeof document === 'undefined') {
|
|
@@ -62,7 +63,13 @@ export function setCookie(
|
|
|
62
63
|
export function removeCookie(name: string) {
|
|
63
64
|
const date = 'Thu, 01 Jan 1970 00:00:00 UTC';
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
const domain =
|
|
67
|
+
settings.localization.localeUrlStrategy === LocaleUrlStrategy.Subdomain
|
|
68
|
+
? getRootHostname(document.location.href)
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
const domainStr = domain ? ` domain=${domain};` : '';
|
|
72
|
+
document.cookie = `${name}=; expires=${date}; path=/;${domainStr}`;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/**
|
package/with-pz-config.js
CHANGED
|
@@ -12,7 +12,8 @@ const defaultConfig = {
|
|
|
12
12
|
output: 'standalone',
|
|
13
13
|
compress: false,
|
|
14
14
|
env: {
|
|
15
|
-
NEXT_PUBLIC_SENTRY_DSN: process.env.SENTRY_DSN
|
|
15
|
+
NEXT_PUBLIC_SENTRY_DSN: process.env.SENTRY_DSN,
|
|
16
|
+
...(process.env.PZ_MOCK && { PZ_MOCK: process.env.PZ_MOCK })
|
|
16
17
|
},
|
|
17
18
|
images: {
|
|
18
19
|
remotePatterns: [
|