@akinon/next 2.0.0-beta.17 → 2.0.0-beta.19
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/components/client-root.tsx +12 -2
- package/components/index.ts +1 -0
- package/components/logger-popup.tsx +213 -0
- package/data/client/basket.ts +39 -0
- package/data/client/checkout.ts +184 -16
- package/hooks/index.ts +2 -0
- package/hooks/use-logger-context.tsx +114 -0
- package/hooks/use-logger.ts +92 -0
- package/package.json +2 -2
- package/utils/app-fetch.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
+
## 2.0.0-beta.19
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cd68a97a: ZERO-4126: Enhance error handling in appFetch and related data handlers to throw notFound on 404 and 422 errors
|
|
8
|
+
|
|
9
|
+
## 2.0.0-beta.18
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 929374c5: ZERO-3278: refactor variable names for better readability and consistency
|
|
14
|
+
- 71f8011d: ZERO-3271: remove code repetition in logger functions using closures
|
|
15
|
+
- 9be2c081: ZERO-3243: Improve basket update query handling with optimistic updates
|
|
16
|
+
- bd431e36: ZERO-3278: improve checkout validation error messages for better user guidance
|
|
17
|
+
- 54eac86b: ZERO-3271: add development logger system
|
|
18
|
+
|
|
3
19
|
## 2.0.0-beta.17
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import React from 'react';
|
|
3
4
|
import { useMobileIframeHandler } from '../hooks';
|
|
5
|
+
import { LoggerPopup } from './logger-popup';
|
|
6
|
+
import { LoggerProvider } from '../hooks/use-logger-context';
|
|
4
7
|
import * as Sentry from '@sentry/nextjs';
|
|
5
8
|
import { initSentry } from '../sentry';
|
|
6
9
|
import { useEffect } from 'react';
|
|
@@ -12,7 +15,9 @@ export default function ClientRoot({
|
|
|
12
15
|
children: React.ReactNode;
|
|
13
16
|
sessionId?: string;
|
|
14
17
|
}) {
|
|
15
|
-
const { preventPageRender } = useMobileIframeHandler({
|
|
18
|
+
const { preventPageRender } = useMobileIframeHandler({
|
|
19
|
+
sessionId: sessionId || ''
|
|
20
|
+
});
|
|
16
21
|
|
|
17
22
|
const initializeSentry = async () => {
|
|
18
23
|
const response = await fetch('/api/sentry', { next: { revalidate: 0 } });
|
|
@@ -35,5 +40,10 @@ export default function ClientRoot({
|
|
|
35
40
|
return null;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
<LoggerProvider>
|
|
45
|
+
{children}
|
|
46
|
+
<LoggerPopup />
|
|
47
|
+
</LoggerProvider>
|
|
48
|
+
);
|
|
39
49
|
}
|
package/components/index.ts
CHANGED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, memo, useMemo } from 'react';
|
|
4
|
+
import { LogEntry } from '../hooks/use-logger';
|
|
5
|
+
import { useLoggerContext } from '../hooks/use-logger-context';
|
|
6
|
+
|
|
7
|
+
const LoggerAnimations = ({ color = '#dc2626' }: { color?: string }) => (
|
|
8
|
+
<style jsx global>{`
|
|
9
|
+
@keyframes pulse {
|
|
10
|
+
0% {
|
|
11
|
+
transform: scale(0.95);
|
|
12
|
+
box-shadow: 0 0 0 0 ${color}80;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
70% {
|
|
16
|
+
transform: scale(1.05);
|
|
17
|
+
box-shadow: 0 0 0 10px ${color}00;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
100% {
|
|
21
|
+
transform: scale(0.95);
|
|
22
|
+
box-shadow: 0 0 0 0 ${color}00;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`}</style>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const LogLevelColors = {
|
|
29
|
+
warn: '#ff9800', // orange
|
|
30
|
+
error: '#f44336' // red
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const LoggerTrigger = memo(
|
|
34
|
+
({
|
|
35
|
+
onClick,
|
|
36
|
+
logCount = 0,
|
|
37
|
+
currentColor
|
|
38
|
+
}: {
|
|
39
|
+
onClick: () => void;
|
|
40
|
+
logCount?: number;
|
|
41
|
+
currentColor: string;
|
|
42
|
+
}) => {
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
onClick={onClick}
|
|
46
|
+
className="fixed bottom-4 right-4 w-14 h-14 border border-white rounded-full flex items-center justify-center shadow-lg z-[9999] hover:opacity-90 transition-colors"
|
|
47
|
+
aria-label="Open Logger"
|
|
48
|
+
style={{
|
|
49
|
+
backgroundColor: currentColor,
|
|
50
|
+
...(logCount > 0 && {
|
|
51
|
+
animation: 'pulse 2s infinite'
|
|
52
|
+
})
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<svg
|
|
56
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
57
|
+
fill="none"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
strokeWidth="1.5"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
className="text-white size-6"
|
|
62
|
+
>
|
|
63
|
+
<path
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
|
67
|
+
/>
|
|
68
|
+
</svg>
|
|
69
|
+
|
|
70
|
+
{logCount > 0 && (
|
|
71
|
+
<span
|
|
72
|
+
className="absolute w-5 h-5 -bottom-[5px] p-1 -left-[5px] border-2 border-white rounded-full flex items-center justify-center text-[8px] text-white font-bold"
|
|
73
|
+
style={{
|
|
74
|
+
backgroundColor: currentColor
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{logCount > 99 ? '99+' : logCount}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
LoggerTrigger.displayName = 'LoggerTrigger';
|
|
86
|
+
|
|
87
|
+
const LogItem = memo(({ log }: { log: LogEntry }) => {
|
|
88
|
+
const [expanded, setExpanded] = useState(false);
|
|
89
|
+
const hasPayload = log.payload && Object.keys(log.payload).length > 0;
|
|
90
|
+
|
|
91
|
+
const toggleExpanded = useCallback(() => {
|
|
92
|
+
setExpanded((prev) => !prev);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="border-b border-gray-200 py-2">
|
|
97
|
+
<div className="relative">
|
|
98
|
+
<div
|
|
99
|
+
className="absolute top-0 left-0 w-3 h-3 rounded-full mt-1.5 mr-2 flex-shrink-0"
|
|
100
|
+
style={{
|
|
101
|
+
backgroundColor: LogLevelColors[log.level],
|
|
102
|
+
boxShadow: `0 0 5px ${LogLevelColors[log.level]}`
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
<div className="ml-6">
|
|
106
|
+
<div className="flex justify-between">
|
|
107
|
+
<span
|
|
108
|
+
className="font-medium capitalize"
|
|
109
|
+
style={{
|
|
110
|
+
color: LogLevelColors[log.level]
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{log.level}
|
|
114
|
+
</span>
|
|
115
|
+
<span className="text-xs text-gray-500">
|
|
116
|
+
{log.timestamp.toLocaleTimeString()}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<p className="text-sm">{log.message}</p>
|
|
120
|
+
{hasPayload && (
|
|
121
|
+
<button
|
|
122
|
+
onClick={toggleExpanded}
|
|
123
|
+
className="text-xs text-blue-500 mt-1 hover:text-blue-700 transition-colors"
|
|
124
|
+
>
|
|
125
|
+
{expanded ? 'Hide Details' : 'Show Details'}
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
{expanded && hasPayload && (
|
|
129
|
+
<pre className="text-xs bg-gray-100 p-2 mt-1 rounded overflow-auto max-h-96 max-w-full">
|
|
130
|
+
{JSON.stringify(log.payload, null, 2)}
|
|
131
|
+
</pre>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
LogItem.displayName = 'LogItem';
|
|
140
|
+
|
|
141
|
+
export const LoggerPopup = () => {
|
|
142
|
+
const {
|
|
143
|
+
logs,
|
|
144
|
+
isVisible,
|
|
145
|
+
toggleVisibility,
|
|
146
|
+
clearLogs,
|
|
147
|
+
isDevelopment,
|
|
148
|
+
hasError,
|
|
149
|
+
hasWarning
|
|
150
|
+
} = useLoggerContext();
|
|
151
|
+
|
|
152
|
+
const currentColor = useMemo(() => {
|
|
153
|
+
if (logs.length === 0) return '#b5afaf';
|
|
154
|
+
if (hasError) return '#dc2626';
|
|
155
|
+
if (hasWarning) return '#ff9800';
|
|
156
|
+
|
|
157
|
+
return '#b5afaf';
|
|
158
|
+
}, [logs.length, hasError, hasWarning]);
|
|
159
|
+
|
|
160
|
+
if (!isDevelopment) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!isVisible) {
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<LoggerAnimations color={currentColor} />
|
|
168
|
+
<LoggerTrigger
|
|
169
|
+
onClick={toggleVisibility}
|
|
170
|
+
logCount={logs.length}
|
|
171
|
+
currentColor={currentColor}
|
|
172
|
+
/>
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
<LoggerAnimations color={currentColor} />
|
|
180
|
+
<LoggerTrigger
|
|
181
|
+
onClick={toggleVisibility}
|
|
182
|
+
logCount={logs.length}
|
|
183
|
+
currentColor={currentColor}
|
|
184
|
+
/>
|
|
185
|
+
<div className="fixed bottom-20 right-4 w-96 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-xl z-50 border-2 border-gray-200 max-h-[70vh] flex flex-col">
|
|
186
|
+
<div className="flex items-center justify-between p-3 border-b border-gray-200">
|
|
187
|
+
<h3 className="font-bold flex items-center">Development Logger</h3>
|
|
188
|
+
<div className="flex space-x-2">
|
|
189
|
+
<button
|
|
190
|
+
onClick={clearLogs}
|
|
191
|
+
className="text-xs bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded transition-colors"
|
|
192
|
+
>
|
|
193
|
+
Clear
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
onClick={toggleVisibility}
|
|
197
|
+
className="text-xs bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded transition-colors"
|
|
198
|
+
>
|
|
199
|
+
Close
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
<div className="overflow-y-auto flex-grow p-3">
|
|
204
|
+
{logs.length === 0 ? (
|
|
205
|
+
<p className="text-gray-500 text-center py-4">No logs yet</p>
|
|
206
|
+
) : (
|
|
207
|
+
logs.map((log) => <LogItem key={log.id} log={log} />)
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
};
|
package/data/client/basket.ts
CHANGED
|
@@ -104,6 +104,45 @@ export const basketApi = api.injectEndpoints({
|
|
|
104
104
|
method: 'PUT',
|
|
105
105
|
body
|
|
106
106
|
}),
|
|
107
|
+
async onQueryStarted(_, { dispatch, queryFulfilled }) {
|
|
108
|
+
try {
|
|
109
|
+
const { data } = await queryFulfilled;
|
|
110
|
+
|
|
111
|
+
dispatch(
|
|
112
|
+
basketApi.util.updateQueryData(
|
|
113
|
+
'getBasket',
|
|
114
|
+
undefined,
|
|
115
|
+
() => data.basket
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (data.basket.namespace) {
|
|
120
|
+
dispatch(
|
|
121
|
+
basketApi.util.updateQueryData(
|
|
122
|
+
'getBasketDetail',
|
|
123
|
+
{ namespace: data.basket.namespace },
|
|
124
|
+
() => data.basket
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispatch(
|
|
130
|
+
basketApi.util.updateQueryData(
|
|
131
|
+
'getAllBaskets',
|
|
132
|
+
undefined,
|
|
133
|
+
(baskets) => {
|
|
134
|
+
if (!baskets) return baskets;
|
|
135
|
+
|
|
136
|
+
return baskets.map((basket) =>
|
|
137
|
+
basket.pk === data.basket.pk ? data.basket : basket
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error('Error updating quantity:', error);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
107
146
|
invalidatesTags: ['MultiBasket', 'Basket', 'MiniBasket']
|
|
108
147
|
}),
|
|
109
148
|
clearBasket: build.mutation<Basket, void>({
|
package/data/client/checkout.ts
CHANGED
|
@@ -33,6 +33,16 @@ import {
|
|
|
33
33
|
buildDirectPurchaseForm,
|
|
34
34
|
buildPurchaseForm
|
|
35
35
|
} from '@akinon/pz-masterpass/src/utils';
|
|
36
|
+
import { devLogger } from '@akinon/next/hooks/use-logger-context';
|
|
37
|
+
import { LogLevel } from '@akinon/next/hooks/use-logger';
|
|
38
|
+
|
|
39
|
+
const getLatestState = async (getState: () => any): Promise<any> => {
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
41
|
+
|
|
42
|
+
return getState();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const recentLogMessages = new Map<string, number>();
|
|
36
46
|
|
|
37
47
|
const getStore = async (): Promise<AppStore> => {
|
|
38
48
|
const { store } = await import('redux/store');
|
|
@@ -49,6 +59,53 @@ interface CheckoutResponse {
|
|
|
49
59
|
redirect_url?: string;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
const validateCheckoutState = (
|
|
63
|
+
state: any,
|
|
64
|
+
validations: Array<{
|
|
65
|
+
condition: (state: any) => boolean;
|
|
66
|
+
errorMessage: string;
|
|
67
|
+
severity?: LogLevel;
|
|
68
|
+
data?: any;
|
|
69
|
+
action?: () => void;
|
|
70
|
+
}>
|
|
71
|
+
) => {
|
|
72
|
+
validations.forEach(
|
|
73
|
+
({
|
|
74
|
+
condition,
|
|
75
|
+
errorMessage,
|
|
76
|
+
severity = 'error',
|
|
77
|
+
data: logData,
|
|
78
|
+
action
|
|
79
|
+
}) => {
|
|
80
|
+
if (condition(state)) {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const lastLogged = recentLogMessages.get(errorMessage) || 0;
|
|
83
|
+
|
|
84
|
+
if (now - lastLogged > 2000) {
|
|
85
|
+
recentLogMessages.set(errorMessage, now);
|
|
86
|
+
|
|
87
|
+
switch (severity) {
|
|
88
|
+
case 'error':
|
|
89
|
+
devLogger.error(
|
|
90
|
+
errorMessage,
|
|
91
|
+
logData || state.checkout?.preOrder
|
|
92
|
+
);
|
|
93
|
+
action?.();
|
|
94
|
+
break;
|
|
95
|
+
case 'warn':
|
|
96
|
+
devLogger.warn(errorMessage, logData || state.checkout?.preOrder);
|
|
97
|
+
action?.();
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
devLogger.info(errorMessage, logData || state.checkout?.preOrder);
|
|
101
|
+
action?.();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
52
109
|
interface SetAddressesParams {
|
|
53
110
|
shippingAddressPk: number;
|
|
54
111
|
billingAddressPk: number;
|
|
@@ -195,6 +252,20 @@ const completeMasterpassPayment = async (
|
|
|
195
252
|
});
|
|
196
253
|
};
|
|
197
254
|
|
|
255
|
+
let checkoutAbortController = new AbortController();
|
|
256
|
+
|
|
257
|
+
export const getCheckoutAbortSignal = () => {
|
|
258
|
+
if (checkoutAbortController.signal.aborted) {
|
|
259
|
+
checkoutAbortController = new AbortController();
|
|
260
|
+
}
|
|
261
|
+
return checkoutAbortController.signal;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const abortCheckout = () => {
|
|
265
|
+
checkoutAbortController.abort();
|
|
266
|
+
checkoutAbortController = new AbortController();
|
|
267
|
+
};
|
|
268
|
+
|
|
198
269
|
export const checkoutApi = api.injectEndpoints({
|
|
199
270
|
endpoints: (build) => ({
|
|
200
271
|
fetchCheckout: build.query<CheckoutResponse, void>({
|
|
@@ -381,12 +452,33 @@ export const checkoutApi = api.injectEndpoints({
|
|
|
381
452
|
method: 'POST',
|
|
382
453
|
body: {
|
|
383
454
|
delivery_option: String(pk)
|
|
384
|
-
}
|
|
455
|
+
},
|
|
456
|
+
signal: getCheckoutAbortSignal()
|
|
385
457
|
}),
|
|
386
|
-
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
458
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
|
|
387
459
|
dispatch(setShippingStepBusy(true));
|
|
388
|
-
|
|
389
|
-
|
|
460
|
+
|
|
461
|
+
const state = await getLatestState(getState);
|
|
462
|
+
|
|
463
|
+
validateCheckoutState(state, [
|
|
464
|
+
{
|
|
465
|
+
condition: (state) => {
|
|
466
|
+
const preOrder = state.checkout?.preOrder;
|
|
467
|
+
|
|
468
|
+
return preOrder?.basket?.basketitem_set?.length === 0;
|
|
469
|
+
},
|
|
470
|
+
errorMessage:
|
|
471
|
+
'Your shopping basket is empty. Please add items to your basket before selecting a delivery option.',
|
|
472
|
+
action: () => abortCheckout()
|
|
473
|
+
}
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await queryFulfilled;
|
|
478
|
+
dispatch(setShippingStepBusy(false));
|
|
479
|
+
} catch (error) {
|
|
480
|
+
dispatch(setShippingStepBusy(false));
|
|
481
|
+
}
|
|
390
482
|
}
|
|
391
483
|
}),
|
|
392
484
|
setAddresses: build.mutation<CheckoutResponse, SetAddressesParams>({
|
|
@@ -398,12 +490,36 @@ export const checkoutApi = api.injectEndpoints({
|
|
|
398
490
|
body: {
|
|
399
491
|
shipping_address: String(shippingAddressPk),
|
|
400
492
|
billing_address: String(billingAddressPk)
|
|
401
|
-
}
|
|
493
|
+
},
|
|
494
|
+
signal: getCheckoutAbortSignal()
|
|
402
495
|
}),
|
|
403
|
-
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
496
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
|
|
404
497
|
dispatch(setShippingStepBusy(true));
|
|
405
|
-
|
|
406
|
-
|
|
498
|
+
|
|
499
|
+
const state = await getLatestState(getState);
|
|
500
|
+
|
|
501
|
+
validateCheckoutState(state, [
|
|
502
|
+
{
|
|
503
|
+
condition: (state) => {
|
|
504
|
+
const deliveryOptions = state.checkout?.deliveryOptions;
|
|
505
|
+
const preOrder = state.checkout?.preOrder;
|
|
506
|
+
|
|
507
|
+
return deliveryOptions?.length > 0
|
|
508
|
+
? preOrder && !preOrder.delivery_option?.pk
|
|
509
|
+
: false;
|
|
510
|
+
},
|
|
511
|
+
errorMessage:
|
|
512
|
+
'You need to select a delivery option before setting your addresses. Dispatch setAddresses action after delivery option selection.',
|
|
513
|
+
action: () => abortCheckout()
|
|
514
|
+
}
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
await queryFulfilled;
|
|
519
|
+
dispatch(setShippingStepBusy(false));
|
|
520
|
+
} catch (error) {
|
|
521
|
+
dispatch(setShippingStepBusy(false));
|
|
522
|
+
}
|
|
407
523
|
}
|
|
408
524
|
}),
|
|
409
525
|
setShippingOption: build.mutation<CheckoutResponse, number>({
|
|
@@ -414,12 +530,43 @@ export const checkoutApi = api.injectEndpoints({
|
|
|
414
530
|
method: 'POST',
|
|
415
531
|
body: {
|
|
416
532
|
shipping_option: String(pk)
|
|
417
|
-
}
|
|
533
|
+
},
|
|
534
|
+
signal: getCheckoutAbortSignal()
|
|
418
535
|
}),
|
|
419
|
-
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
536
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
|
|
420
537
|
dispatch(setShippingStepBusy(true));
|
|
421
|
-
|
|
422
|
-
|
|
538
|
+
|
|
539
|
+
const state = await getLatestState(getState);
|
|
540
|
+
|
|
541
|
+
validateCheckoutState(state, [
|
|
542
|
+
{
|
|
543
|
+
condition: (state) => {
|
|
544
|
+
const preOrder = state.checkout?.preOrder;
|
|
545
|
+
|
|
546
|
+
return !preOrder?.billing_address;
|
|
547
|
+
},
|
|
548
|
+
errorMessage:
|
|
549
|
+
'You need to provide a billing address before selecting a shipping option. Dispatch setShippingOption action after billing address selection.',
|
|
550
|
+
action: () => abortCheckout()
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
condition: (state) => {
|
|
554
|
+
const preOrder = state.checkout?.preOrder;
|
|
555
|
+
|
|
556
|
+
return !preOrder?.shipping_address;
|
|
557
|
+
},
|
|
558
|
+
errorMessage:
|
|
559
|
+
'You need to provide a shipping address before selecting a shipping option. Dispatch setShippingOption action after shipping address selection.',
|
|
560
|
+
action: () => abortCheckout()
|
|
561
|
+
}
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
await queryFulfilled;
|
|
566
|
+
dispatch(setShippingStepBusy(false));
|
|
567
|
+
} catch (error) {
|
|
568
|
+
dispatch(setShippingStepBusy(false));
|
|
569
|
+
}
|
|
423
570
|
}
|
|
424
571
|
}),
|
|
425
572
|
setDataSourceShippingOptions: build.mutation<CheckoutResponse, number[]>({
|
|
@@ -467,16 +614,37 @@ export const checkoutApi = api.injectEndpoints({
|
|
|
467
614
|
method: 'POST',
|
|
468
615
|
body: {
|
|
469
616
|
payment_option: String(pk)
|
|
470
|
-
}
|
|
617
|
+
},
|
|
618
|
+
signal: getCheckoutAbortSignal()
|
|
471
619
|
}),
|
|
472
|
-
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
620
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
|
|
473
621
|
dispatch(setPaymentStepBusy(true));
|
|
474
622
|
dispatch(setInstallmentOptions([]));
|
|
475
623
|
dispatch(setBankAccounts([]));
|
|
476
624
|
dispatch(setSelectedBankAccountPk(null));
|
|
477
625
|
dispatch(setCardType(null));
|
|
478
|
-
|
|
479
|
-
|
|
626
|
+
|
|
627
|
+
const state = await getLatestState(getState);
|
|
628
|
+
|
|
629
|
+
validateCheckoutState(state, [
|
|
630
|
+
{
|
|
631
|
+
condition: (state) => {
|
|
632
|
+
const preOrder = state.checkout?.preOrder;
|
|
633
|
+
|
|
634
|
+
return !preOrder?.shipping_option?.pk;
|
|
635
|
+
},
|
|
636
|
+
errorMessage:
|
|
637
|
+
'You need to select a shipping option before choosing a payment method. Dispatch setPaymentOption action after shipping option selection.',
|
|
638
|
+
action: () => abortCheckout()
|
|
639
|
+
}
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
await queryFulfilled;
|
|
644
|
+
dispatch(setPaymentStepBusy(false));
|
|
645
|
+
} catch (error) {
|
|
646
|
+
dispatch(setPaymentStepBusy(false));
|
|
647
|
+
}
|
|
480
648
|
}
|
|
481
649
|
}),
|
|
482
650
|
setWalletSelectionPage: build.mutation<
|
package/hooks/index.ts
CHANGED
|
@@ -10,5 +10,7 @@ export * from './use-mobile-iframe-handler';
|
|
|
10
10
|
export * from './use-payment-options';
|
|
11
11
|
export * from './use-pagination';
|
|
12
12
|
export * from './use-message-listener';
|
|
13
|
+
export * from './use-logger';
|
|
14
|
+
export * from './use-logger-context';
|
|
13
15
|
export * from './use-sentry-uncaught-errors';
|
|
14
16
|
export * from './use-pz-params';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
ReactNode,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useEffect
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { useLogger, LogEntry, LogLevel } from './use-logger';
|
|
12
|
+
|
|
13
|
+
const LOG_LEVELS: LogLevel[] = ['info', 'warn', 'error'];
|
|
14
|
+
|
|
15
|
+
interface LoggerContextType {
|
|
16
|
+
logs: LogEntry[];
|
|
17
|
+
isVisible: boolean;
|
|
18
|
+
toggleVisibility: () => void;
|
|
19
|
+
clearLogs: () => void;
|
|
20
|
+
info: (message: string, payload?: any) => string;
|
|
21
|
+
warn: (message: string, payload?: any) => string;
|
|
22
|
+
error: (message: string, payload?: any) => string;
|
|
23
|
+
isDevelopment: boolean;
|
|
24
|
+
hasError: boolean;
|
|
25
|
+
hasWarning: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LoggerContext = createContext<LoggerContextType | undefined>(undefined);
|
|
29
|
+
|
|
30
|
+
let globalAddLogFunction:
|
|
31
|
+
| ((level: string, message: string, payload?: any) => string)
|
|
32
|
+
| null = null;
|
|
33
|
+
|
|
34
|
+
// temporary queue for logs generated before the logger is initialized
|
|
35
|
+
const pendingLogs: Array<{ level: string; message: string; payload?: any }> =
|
|
36
|
+
[];
|
|
37
|
+
|
|
38
|
+
const createLogFunction =
|
|
39
|
+
(level: LogLevel) => (message: string, payload?: any) => {
|
|
40
|
+
if (
|
|
41
|
+
typeof window !== 'undefined' &&
|
|
42
|
+
process.env.NODE_ENV === 'development'
|
|
43
|
+
) {
|
|
44
|
+
try {
|
|
45
|
+
if (globalAddLogFunction) {
|
|
46
|
+
globalAddLogFunction(level, message, payload);
|
|
47
|
+
} else {
|
|
48
|
+
pendingLogs.push({ level, message, payload });
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// prevent errors
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return '';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const stableLogger = LOG_LEVELS.reduce((logger, level) => {
|
|
59
|
+
logger[level] = createLogFunction(level);
|
|
60
|
+
|
|
61
|
+
return logger;
|
|
62
|
+
}, {} as Record<LogLevel, (message: string, payload?: any) => string>);
|
|
63
|
+
|
|
64
|
+
export const LoggerProvider = ({ children }: { children: ReactNode }) => {
|
|
65
|
+
const loggerHook = useLogger();
|
|
66
|
+
|
|
67
|
+
const addLogRef = useRef<
|
|
68
|
+
(level: string, message: string, payload?: any) => string
|
|
69
|
+
>((level, message, payload) => {
|
|
70
|
+
if (LOG_LEVELS.includes(level as LogLevel)) {
|
|
71
|
+
return loggerHook[level as LogLevel](message, payload);
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
globalAddLogFunction = addLogRef.current;
|
|
78
|
+
|
|
79
|
+
if (pendingLogs.length > 0) {
|
|
80
|
+
pendingLogs.forEach((log) => {
|
|
81
|
+
if (globalAddLogFunction) {
|
|
82
|
+
globalAddLogFunction(log.level, log.message, log.payload);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pendingLogs.length = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
globalAddLogFunction = null;
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const contextValue = useMemo(
|
|
95
|
+
() => loggerHook,
|
|
96
|
+
[loggerHook.logs, loggerHook.isVisible, loggerHook.isDevelopment] // eslint-disable-line react-hooks/exhaustive-deps
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<LoggerContext.Provider value={contextValue}>
|
|
101
|
+
{children}
|
|
102
|
+
</LoggerContext.Provider>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const useLoggerContext = () => {
|
|
107
|
+
const context = useContext(LoggerContext);
|
|
108
|
+
if (context === undefined) {
|
|
109
|
+
throw new Error('useLoggerContext must be used within a LoggerProvider');
|
|
110
|
+
}
|
|
111
|
+
return context;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const devLogger = stableLogger;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
export type LogLevel = 'info' | 'warn' | 'error';
|
|
4
|
+
|
|
5
|
+
export interface LogEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
level: LogLevel;
|
|
8
|
+
message: string;
|
|
9
|
+
timestamp: Date;
|
|
10
|
+
payload?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LOG_LEVELS: LogEntry['level'][] = ['info', 'warn', 'error'];
|
|
14
|
+
|
|
15
|
+
export function useLogger() {
|
|
16
|
+
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
17
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
18
|
+
const logsRef = useRef<LogEntry[]>([]);
|
|
19
|
+
const [isDevelopment, setIsDevelopment] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setIsDevelopment(
|
|
23
|
+
process.env.NODE_ENV === 'development' ||
|
|
24
|
+
window.location.hostname === 'localhost' ||
|
|
25
|
+
window.location.hostname === '127.0.0.1'
|
|
26
|
+
);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
logsRef.current = logs;
|
|
31
|
+
}, [logs]);
|
|
32
|
+
|
|
33
|
+
const addLog = useCallback(
|
|
34
|
+
(level: LogEntry['level'], message: string, payload?: any) => {
|
|
35
|
+
const newLog: LogEntry = {
|
|
36
|
+
id: Math.random().toString(36).substring(2, 9),
|
|
37
|
+
level,
|
|
38
|
+
message,
|
|
39
|
+
timestamp: new Date(),
|
|
40
|
+
payload
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
|
44
|
+
|
|
45
|
+
return newLog.id;
|
|
46
|
+
},
|
|
47
|
+
[]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const clearLogs = useCallback(() => {
|
|
51
|
+
setLogs([]);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const toggleVisibility = useCallback(() => {
|
|
55
|
+
setIsVisible((prev) => !prev);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const createLogMethod = useCallback(
|
|
59
|
+
(level: LogEntry['level']) => (message: string, payload?: any) =>
|
|
60
|
+
addLog(level, message, payload),
|
|
61
|
+
[addLog]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const logMethods = useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
LOG_LEVELS.reduce((methods, level) => {
|
|
67
|
+
methods[level] = createLogMethod(level);
|
|
68
|
+
return methods;
|
|
69
|
+
}, {} as Record<LogEntry['level'], (message: string, payload?: any) => string>),
|
|
70
|
+
[createLogMethod]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const hasError = useMemo(
|
|
74
|
+
() => logs.some((log) => log.level === 'error'),
|
|
75
|
+
[logs]
|
|
76
|
+
);
|
|
77
|
+
const hasWarning = useMemo(
|
|
78
|
+
() => logs.some((log) => log.level === 'warn'),
|
|
79
|
+
[logs]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
logs,
|
|
84
|
+
isVisible,
|
|
85
|
+
toggleVisibility,
|
|
86
|
+
clearLogs,
|
|
87
|
+
...logMethods,
|
|
88
|
+
isDevelopment,
|
|
89
|
+
hasError,
|
|
90
|
+
hasWarning
|
|
91
|
+
};
|
|
92
|
+
}
|
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.0-beta.
|
|
4
|
+
"version": "2.0.0-beta.19",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"set-cookie-parser": "2.6.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@akinon/eslint-plugin-projectzero": "2.0.0-beta.
|
|
38
|
+
"@akinon/eslint-plugin-projectzero": "2.0.0-beta.19",
|
|
39
39
|
"@babel/core": "7.26.10",
|
|
40
40
|
"@babel/preset-env": "7.26.9",
|
|
41
41
|
"@babel/preset-typescript": "7.27.0",
|
package/utils/app-fetch.ts
CHANGED
|
@@ -2,6 +2,7 @@ import Settings from 'settings';
|
|
|
2
2
|
import logger from '../utils/log';
|
|
3
3
|
import { headers, cookies } from 'next/headers';
|
|
4
4
|
import { ServerVariables } from './server-variables';
|
|
5
|
+
import { notFound } from 'next/navigation';
|
|
5
6
|
|
|
6
7
|
export enum FetchResponseType {
|
|
7
8
|
JSON = 'json',
|
|
@@ -75,6 +76,10 @@ const appFetch = async <T>({
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
if (status === 422) {
|
|
80
|
+
notFound();
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
return response;
|
|
79
84
|
};
|
|
80
85
|
|