@akinon/next 2.0.0-beta.16 → 2.0.0-beta.18

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.
@@ -10,6 +10,7 @@ import {
10
10
  } from '../../redux/reducers/checkout';
11
11
  import {
12
12
  CheckoutContext,
13
+ AccountUsage,
13
14
  ExtraField,
14
15
  GuestLoginFormParams,
15
16
  Order,
@@ -32,6 +33,16 @@ import {
32
33
  buildDirectPurchaseForm,
33
34
  buildPurchaseForm
34
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>();
35
46
 
36
47
  const getStore = async (): Promise<AppStore> => {
37
48
  const { store } = await import('redux/store');
@@ -48,6 +59,53 @@ interface CheckoutResponse {
48
59
  redirect_url?: string;
49
60
  }
50
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
+
51
109
  interface SetAddressesParams {
52
110
  shippingAddressPk: number;
53
111
  billingAddressPk: number;
@@ -194,6 +252,20 @@ const completeMasterpassPayment = async (
194
252
  });
195
253
  };
196
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
+
197
269
  export const checkoutApi = api.injectEndpoints({
198
270
  endpoints: (build) => ({
199
271
  fetchCheckout: build.query<CheckoutResponse, void>({
@@ -380,12 +452,33 @@ export const checkoutApi = api.injectEndpoints({
380
452
  method: 'POST',
381
453
  body: {
382
454
  delivery_option: String(pk)
383
- }
455
+ },
456
+ signal: getCheckoutAbortSignal()
384
457
  }),
385
- async onQueryStarted(arg, { dispatch, queryFulfilled }) {
458
+ async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
386
459
  dispatch(setShippingStepBusy(true));
387
- await queryFulfilled;
388
- dispatch(setShippingStepBusy(false));
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
+ }
389
482
  }
390
483
  }),
391
484
  setAddresses: build.mutation<CheckoutResponse, SetAddressesParams>({
@@ -397,12 +490,36 @@ export const checkoutApi = api.injectEndpoints({
397
490
  body: {
398
491
  shipping_address: String(shippingAddressPk),
399
492
  billing_address: String(billingAddressPk)
400
- }
493
+ },
494
+ signal: getCheckoutAbortSignal()
401
495
  }),
402
- async onQueryStarted(arg, { dispatch, queryFulfilled }) {
496
+ async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
403
497
  dispatch(setShippingStepBusy(true));
404
- await queryFulfilled;
405
- dispatch(setShippingStepBusy(false));
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
+ }
406
523
  }
407
524
  }),
408
525
  setShippingOption: build.mutation<CheckoutResponse, number>({
@@ -413,12 +530,43 @@ export const checkoutApi = api.injectEndpoints({
413
530
  method: 'POST',
414
531
  body: {
415
532
  shipping_option: String(pk)
416
- }
533
+ },
534
+ signal: getCheckoutAbortSignal()
417
535
  }),
418
- async onQueryStarted(arg, { dispatch, queryFulfilled }) {
536
+ async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
419
537
  dispatch(setShippingStepBusy(true));
420
- await queryFulfilled;
421
- dispatch(setShippingStepBusy(false));
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
+ }
422
570
  }
423
571
  }),
424
572
  setDataSourceShippingOptions: build.mutation<CheckoutResponse, number[]>({
@@ -466,16 +614,37 @@ export const checkoutApi = api.injectEndpoints({
466
614
  method: 'POST',
467
615
  body: {
468
616
  payment_option: String(pk)
469
- }
617
+ },
618
+ signal: getCheckoutAbortSignal()
470
619
  }),
471
- async onQueryStarted(arg, { dispatch, queryFulfilled }) {
620
+ async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
472
621
  dispatch(setPaymentStepBusy(true));
473
622
  dispatch(setInstallmentOptions([]));
474
623
  dispatch(setBankAccounts([]));
475
624
  dispatch(setSelectedBankAccountPk(null));
476
625
  dispatch(setCardType(null));
477
- await queryFulfilled;
478
- dispatch(setPaymentStepBusy(false));
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
+ }
479
648
  }
480
649
  }),
481
650
  setWalletSelectionPage: build.mutation<
@@ -776,16 +945,28 @@ export const checkoutApi = api.injectEndpoints({
776
945
  url: buildCheckoutRequestUrl(checkout.loyaltyMoneyUsage)
777
946
  })
778
947
  }),
779
- payWithLoyaltyBalance: build.mutation<any, string>({
780
- query: (amount) => ({
781
- url: buildCheckoutRequestUrl(checkout.loyaltyMoneyUsage, {
782
- useFormData: true
783
- }),
784
- method: 'POST',
785
- body: {
786
- loyalty_amount_to_use: amount
787
- }
788
- }),
948
+ payWithLoyaltyBalance: build.mutation<
949
+ any,
950
+ string | { account_usages: AccountUsage[] }
951
+ >({
952
+ query: (params) => {
953
+ const isAccountUsages =
954
+ typeof params === 'object' && 'account_usages' in params;
955
+
956
+ return {
957
+ url: buildCheckoutRequestUrl(checkout.loyaltyMoneyUsage, {
958
+ useFormData: true
959
+ }),
960
+ method: 'POST',
961
+ body: isAccountUsages
962
+ ? {
963
+ account_usages: JSON.stringify(params.account_usages)
964
+ }
965
+ : {
966
+ loyalty_amount_to_use: params
967
+ }
968
+ };
969
+ },
789
970
  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
790
971
  dispatch(setPaymentStepBusy(true));
791
972
  dispatch(setPaymentOptions([]));
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
+ }
@@ -0,0 +1,18 @@
1
+ import { NextMiddleware } from 'next/server';
2
+
3
+ const withBfcacheHeaders = (middleware: NextMiddleware): NextMiddleware => {
4
+ return async (req, event) => {
5
+ const response = await middleware(req, event);
6
+
7
+ if (process.env.BF_CACHE === 'true' && response) {
8
+ response.headers.set(
9
+ 'Cache-Control',
10
+ 'private, no-cache, max-age=0, must-revalidate'
11
+ );
12
+ }
13
+
14
+ return response;
15
+ };
16
+ };
17
+
18
+ export default withBfcacheHeaders;
@@ -14,7 +14,8 @@ import {
14
14
  withUrlRedirection,
15
15
  withCompleteWallet,
16
16
  withWalletCompleteRedirection,
17
- withMasterpassRestCallback
17
+ withMasterpassRestCallback,
18
+ withBfcacheHeaders
18
19
  } from '.';
19
20
  import { urlLocaleMatcherRegex } from '../utils';
20
21
  import { getPzSegmentsConfig, encodePzValue, isLegacyMode } from '../utils/pz-segments';
@@ -256,10 +257,11 @@ const withPzDefault =
256
257
  withCompleteWallet(
257
258
  withWalletCompleteRedirection(
258
259
  withMasterpassRestCallback(
259
- async (
260
- req: PzNextRequest,
261
- event: NextFetchEvent
262
- ) => {
260
+ withBfcacheHeaders(
261
+ async (
262
+ req: PzNextRequest,
263
+ event: NextFetchEvent
264
+ ) => {
263
265
  let middlewareResult: NextResponse | void =
264
266
  NextResponse.next();
265
267
 
@@ -563,7 +565,7 @@ const withPzDefault =
563
565
  }
564
566
 
565
567
  return middlewareResult;
566
- }
568
+ })
567
569
  )
568
570
  )
569
571
  )
@@ -12,6 +12,7 @@ import withSavedCardRedirection from './saved-card-redirection';
12
12
  import withCompleteWallet from './complete-wallet';
13
13
  import withWalletCompleteRedirection from './wallet-complete-redirection';
14
14
  import withMasterpassRestCallback from './masterpass-rest-callback';
15
+ import withBfcacheHeaders from './bfcache-headers';
15
16
  import { NextRequest } from 'next/server';
16
17
 
17
18
  export {
@@ -28,7 +29,8 @@ export {
28
29
  withSavedCardRedirection,
29
30
  withCompleteWallet,
30
31
  withWalletCompleteRedirection,
31
- withMasterpassRestCallback
32
+ withMasterpassRestCallback,
33
+ withBfcacheHeaders
32
34
  };
33
35
 
34
36
  export interface PzNextRequest extends NextRequest {