@akinon/next 2.0.0-beta.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +438 -21
  2. package/__tests__/next-config.test.ts +83 -0
  3. package/__tests__/tsconfig.json +23 -0
  4. package/api/auth.ts +367 -63
  5. package/api/barcode-search.ts +59 -0
  6. package/api/cache.ts +41 -5
  7. package/api/client.ts +21 -4
  8. package/api/form.ts +85 -0
  9. package/api/image-proxy.ts +75 -0
  10. package/api/product-categories.ts +53 -0
  11. package/api/similar-product-list.ts +63 -0
  12. package/api/similar-products.ts +111 -0
  13. package/api/virtual-try-on.ts +382 -0
  14. package/assets/styles/index.scss +84 -0
  15. package/babel.config.js +6 -0
  16. package/bin/pz-generate-routes.js +115 -0
  17. package/bin/pz-install-plugins.js +1 -1
  18. package/bin/pz-prebuild.js +1 -0
  19. package/bin/pz-predev.js +1 -0
  20. package/bin/pz-run-tests.js +99 -0
  21. package/components/accordion.tsx +21 -6
  22. package/components/client-root.tsx +119 -3
  23. package/components/file-input.tsx +65 -3
  24. package/components/index.ts +1 -0
  25. package/components/input.tsx +2 -2
  26. package/components/link.tsx +46 -16
  27. package/components/logger-popup.tsx +213 -0
  28. package/components/modal.tsx +32 -16
  29. package/components/plugin-module.tsx +62 -3
  30. package/components/price.tsx +2 -2
  31. package/components/select.tsx +3 -3
  32. package/components/selected-payment-option-view.tsx +21 -0
  33. package/data/client/account.ts +17 -2
  34. package/data/client/basket.ts +39 -0
  35. package/data/client/checkout.ts +336 -99
  36. package/data/client/misc.ts +13 -1
  37. package/data/server/category.ts +11 -9
  38. package/data/server/flatpage.ts +4 -1
  39. package/data/server/form.ts +4 -1
  40. package/data/server/landingpage.ts +4 -1
  41. package/data/server/list.ts +5 -4
  42. package/data/server/menu.ts +4 -1
  43. package/data/server/product.ts +97 -52
  44. package/data/server/seo.ts +4 -1
  45. package/data/server/special-page.ts +5 -4
  46. package/data/server/widget.ts +71 -1
  47. package/data/urls.ts +6 -3
  48. package/hocs/client/with-segment-defaults.tsx +2 -2
  49. package/hocs/server/with-segment-defaults.tsx +81 -20
  50. package/hooks/index.ts +3 -0
  51. package/hooks/use-localization.ts +24 -10
  52. package/hooks/use-logger-context.tsx +114 -0
  53. package/hooks/use-logger.ts +92 -0
  54. package/hooks/use-loyalty-availability.ts +21 -0
  55. package/hooks/use-payment-options.ts +2 -1
  56. package/hooks/use-pz-params.ts +37 -0
  57. package/hooks/use-router.ts +53 -19
  58. package/instrumentation/index.ts +0 -1
  59. package/instrumentation/node.ts +2 -20
  60. package/jest.config.js +25 -0
  61. package/lib/cache-handler.mjs +534 -16
  62. package/lib/cache.ts +269 -34
  63. package/localization/provider.tsx +2 -5
  64. package/middlewares/bfcache-headers.ts +18 -0
  65. package/middlewares/checkout-provider.ts +1 -1
  66. package/middlewares/complete-gpay.ts +32 -26
  67. package/middlewares/complete-masterpass.ts +33 -26
  68. package/middlewares/complete-wallet.ts +182 -0
  69. package/middlewares/default.ts +357 -203
  70. package/middlewares/index.ts +10 -2
  71. package/middlewares/locale.ts +5 -3
  72. package/middlewares/masterpass-rest-callback.ts +230 -0
  73. package/middlewares/oauth-login.ts +200 -57
  74. package/middlewares/pretty-url.ts +21 -8
  75. package/middlewares/redirection-payment.ts +32 -26
  76. package/middlewares/saved-card-redirection.ts +33 -26
  77. package/middlewares/three-d-redirection.ts +32 -26
  78. package/middlewares/url-redirection.ts +9 -15
  79. package/middlewares/wallet-complete-redirection.ts +206 -0
  80. package/package.json +24 -10
  81. package/plugins.d.ts +19 -4
  82. package/plugins.js +9 -1
  83. package/redux/actions.ts +47 -0
  84. package/redux/middlewares/checkout.ts +61 -8
  85. package/redux/middlewares/index.ts +14 -10
  86. package/redux/middlewares/pre-order/address.ts +1 -1
  87. package/redux/middlewares/pre-order/attribute-based-shipping-option.ts +1 -1
  88. package/redux/middlewares/pre-order/data-source-shipping-option.ts +1 -1
  89. package/redux/middlewares/pre-order/delivery-option.ts +1 -1
  90. package/redux/middlewares/pre-order/index.ts +3 -1
  91. package/redux/middlewares/pre-order/installment-option.ts +2 -1
  92. package/redux/middlewares/pre-order/payment-option-reset.ts +37 -0
  93. package/redux/middlewares/pre-order/payment-option.ts +1 -1
  94. package/redux/middlewares/pre-order/pre-order-validation.ts +4 -3
  95. package/redux/middlewares/pre-order/redirection.ts +2 -2
  96. package/redux/middlewares/pre-order/set-pre-order.ts +2 -2
  97. package/redux/middlewares/pre-order/shipping-option.ts +1 -1
  98. package/redux/middlewares/pre-order/shipping-step.ts +1 -1
  99. package/redux/reducers/checkout.ts +15 -1
  100. package/redux/reducers/index.ts +7 -1
  101. package/redux/reducers/widget.ts +80 -0
  102. package/sentry/index.ts +54 -17
  103. package/tailwind/content.js +16 -0
  104. package/types/commerce/checkout.ts +26 -1
  105. package/types/commerce/widget.ts +33 -0
  106. package/types/index.ts +114 -5
  107. package/types/next-auth.d.ts +2 -2
  108. package/types/widget.ts +80 -0
  109. package/utils/app-fetch.ts +7 -2
  110. package/utils/generate-commerce-search-params.ts +3 -2
  111. package/utils/get-checkout-path.ts +3 -0
  112. package/utils/get-root-hostname.ts +28 -0
  113. package/utils/index.ts +69 -18
  114. package/utils/mobile-3d-iframe.ts +8 -2
  115. package/utils/override-middleware.ts +1 -0
  116. package/utils/pz-segments.ts +92 -0
  117. package/utils/redirect-ignore.ts +35 -0
  118. package/utils/redirect.ts +9 -3
  119. package/utils/redirection-iframe.ts +8 -2
  120. package/utils/widget-styles.ts +107 -0
  121. package/with-pz-config.js +20 -7
@@ -7,35 +7,46 @@ import { AccordionProps } from '../types';
7
7
 
8
8
  export const Accordion = ({
9
9
  isCollapse = false,
10
+ collapseClassName,
10
11
  title,
11
12
  subTitle,
12
13
  icons = ['chevron-up', 'chevron-down'],
13
14
  iconSize = 16,
14
15
  iconColor = 'fill-[#000000]',
15
16
  children,
17
+ headerClassName,
16
18
  className,
17
19
  titleClassName,
18
- dataTestId
20
+ subTitleClassName,
21
+ dataTestId,
22
+ contentClassName
19
23
  }: AccordionProps) => {
20
24
  const [collapse, setCollapse] = useState(isCollapse);
21
25
 
22
26
  return (
23
27
  <div
24
28
  className={twMerge(
25
- 'flex flex-col justify-center border-b border-gray-200 pb-4 mb-4 last:border-none',
29
+ 'flex flex-col justify-center border-b pb-4 mb-4 last:border-none',
26
30
  className
27
31
  )}
28
32
  >
29
33
  <div
30
- className="flex items-center justify-between cursor-pointer"
34
+ className={twMerge(
35
+ 'flex items-center justify-between cursor-pointer',
36
+ headerClassName
37
+ )}
31
38
  onClick={() => setCollapse(!collapse)}
32
39
  data-testid={dataTestId}
33
40
  >
34
- <div className="flex flex-col">
41
+ <div className={twMerge('flex flex-col', contentClassName)}>
35
42
  {title && (
36
43
  <h3 className={twMerge('text-sm', titleClassName)}>{title}</h3>
37
44
  )}
38
- {subTitle && <h4 className="text-xs text-gray-700">{subTitle}</h4>}
45
+ {subTitle && (
46
+ <h4 className={twMerge('text-xs text-gray-700', subTitleClassName)}>
47
+ {subTitle}
48
+ </h4>
49
+ )}
39
50
  </div>
40
51
 
41
52
  {icons && (
@@ -46,7 +57,11 @@ export const Accordion = ({
46
57
  />
47
58
  )}
48
59
  </div>
49
- {collapse && <div className="mt-3 text-sm">{children}</div>}
60
+ {collapse && (
61
+ <div className={twMerge('mt-3 text-sm', collapseClassName)}>
62
+ {children}
63
+ </div>
64
+ )}
50
65
  </div>
51
66
  );
52
67
  };
@@ -1,9 +1,24 @@
1
1
  'use client';
2
2
 
3
+ import React from 'react';
4
+ import { useCallback, useEffect } from 'react';
3
5
  import { useMobileIframeHandler } from '../hooks';
6
+ import { useAppDispatch, useAppSelector } from '../redux/hooks';
7
+ import {
8
+ setComponents,
9
+ setDataSources,
10
+ setDesignMode,
11
+ setDraggingActive,
12
+ setPlaceholders,
13
+ setResponsive,
14
+ setSelectedComponentId,
15
+ setSelectedPlaceholder,
16
+ setSelectedWidget
17
+ } from '../redux/reducers/widget';
18
+ import { LoggerPopup } from './logger-popup';
19
+ import { LoggerProvider } from '../hooks/use-logger-context';
4
20
  import * as Sentry from '@sentry/nextjs';
5
21
  import { initSentry } from '../sentry';
6
- import { useEffect } from 'react';
7
22
 
8
23
  export default function ClientRoot({
9
24
  children,
@@ -12,7 +27,103 @@ export default function ClientRoot({
12
27
  children: React.ReactNode;
13
28
  sessionId?: string;
14
29
  }) {
15
- const { preventPageRender } = useMobileIframeHandler({ sessionId });
30
+ const { preventPageRender } = useMobileIframeHandler({
31
+ sessionId: sessionId || ''
32
+ });
33
+ const { components } = useAppSelector((state) => state.widget);
34
+ const dispatch = useAppDispatch();
35
+
36
+ const postMessage = (message: {
37
+ type: string;
38
+ data: Record<string, unknown>;
39
+ }) => {
40
+ window.parent.postMessage(message, '*');
41
+ };
42
+
43
+ const handleWidgetMessage = useCallback(
44
+ (event: MessageEvent) => {
45
+ if (event.data.type === 'UPDATE_COMPONENTS') {
46
+ const { components } = event.data.data;
47
+
48
+ dispatch(setComponents(components));
49
+ } else if (event.data.type === 'UPDATE_WIDGET_SYSTEM') {
50
+ const {
51
+ responsive,
52
+ designMode,
53
+ selectedComponentId,
54
+ placeholders,
55
+ dataSources
56
+ } = event.data.data;
57
+
58
+ dispatch(setResponsive(responsive));
59
+ dispatch(setDesignMode(designMode));
60
+ dispatch(setSelectedComponentId(selectedComponentId));
61
+ dispatch(setPlaceholders(placeholders));
62
+ dispatch(setDataSources(dataSources));
63
+ } else if (event.data.type === 'SELECT_PLACEHOLDER') {
64
+ const { placeholderSlug } = event.data.data;
65
+
66
+ dispatch(setSelectedPlaceholder(placeholderSlug));
67
+ } else if (event.data.type === 'SELECT_WIDGET') {
68
+ const { widgetSlug } = event.data.data;
69
+
70
+ dispatch(setSelectedWidget(widgetSlug));
71
+
72
+ postMessage({
73
+ type: 'SELECT_COMPONENT',
74
+ data: {
75
+ componentId: null
76
+ }
77
+ });
78
+ } else if (event.data.type === 'DRAG_START') {
79
+ dispatch(setDraggingActive(true));
80
+ } else if (event.data.type === 'DRAG_END') {
81
+ dispatch(setDraggingActive(false));
82
+ } else if (event.data.type === 'GET_DROP_TARGET') {
83
+ const { x, y, component } = event.data.data;
84
+ const elements = document.elementsFromPoint(x, y);
85
+ const dropTargetId = elements
86
+ .find((element) => element.hasAttribute('data-component'))
87
+ ?.getAttribute('data-id');
88
+
89
+ postMessage({
90
+ type: 'DROP_TARGET_RESPONSE',
91
+ data: {
92
+ targetId: dropTargetId,
93
+ component
94
+ }
95
+ });
96
+ } else if (event.data.type === 'SET_COOKIE') {
97
+ const { key, value } = event.data.data.value;
98
+
99
+ if (key && value) {
100
+ let cookieString = '';
101
+ if (window.parent !== window) {
102
+ cookieString = `${key}=${value}; path=/; SameSite=None; Secure`;
103
+ } else if (key === 'widget_builder') {
104
+ cookieString = `${key}=false; path=/; SameSite=None; Secure`;
105
+ }
106
+
107
+ if (cookieString) {
108
+ document.cookie = cookieString;
109
+ }
110
+ }
111
+ }
112
+ },
113
+ [dispatch, components]
114
+ );
115
+
116
+ useEffect(() => {
117
+ window.addEventListener('message', handleWidgetMessage);
118
+
119
+ if (window.parent === window) {
120
+ document.cookie = 'widget_builder=false; path=/; SameSite=None; Secure';
121
+ }
122
+
123
+ return () => {
124
+ window.removeEventListener('message', handleWidgetMessage);
125
+ };
126
+ }, [handleWidgetMessage]);
16
127
 
17
128
  const initializeSentry = async () => {
18
129
  const response = await fetch('/api/sentry', { next: { revalidate: 0 } });
@@ -35,5 +146,10 @@ export default function ClientRoot({
35
146
  return null;
36
147
  }
37
148
 
38
- return <>{children}</>;
149
+ return (
150
+ <LoggerProvider>
151
+ {children}
152
+ <LoggerPopup />
153
+ </LoggerProvider>
154
+ );
39
155
  }
@@ -1,8 +1,70 @@
1
+ import { useState } from 'react';
1
2
  import { forwardRef } from 'react';
2
- import { FileInputProps } from '../types/index';
3
+ import { useLocalization } from '@akinon/next/hooks';
4
+ import { twMerge } from 'tailwind-merge';
5
+ import { FileInputProps } from '../types';
3
6
 
4
7
  export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(
5
- function fileInput(props, ref) {
6
- return <input type="file" {...props} ref={ref} />;
8
+ function FileInput(
9
+ {
10
+ buttonClassName,
11
+ onChange,
12
+ fileClassName,
13
+ fileNameWrapperClassName,
14
+ fileInputClassName,
15
+ ...props
16
+ },
17
+ ref
18
+ ) {
19
+ const { t } = useLocalization();
20
+ const [fileNames, setFileNames] = useState<string[]>([]);
21
+
22
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
23
+ const files = Array.from(event.target.files || []);
24
+ setFileNames(files.map((file) => file.name));
25
+
26
+ if (onChange) {
27
+ onChange(event);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="relative">
33
+ <input
34
+ type="file"
35
+ {...props}
36
+ ref={ref}
37
+ className={twMerge(
38
+ 'absolute inset-0 w-full h-full opacity-0 cursor-pointer',
39
+ fileInputClassName
40
+ )}
41
+ onChange={handleFileChange}
42
+ />
43
+ <button
44
+ type="button"
45
+ className={twMerge(
46
+ 'bg-primary text-white py-2 px-4 text-sm',
47
+ buttonClassName
48
+ )}
49
+ >
50
+ {t('common.file_input.select_file')}
51
+ </button>
52
+ <div
53
+ className={twMerge('mt-1 text-gray-500', fileNameWrapperClassName)}
54
+ >
55
+ {fileNames.length > 0 ? (
56
+ <ul className={twMerge('list-disc pl-4 text-xs', fileClassName)}>
57
+ {fileNames.map((name, index) => (
58
+ <li key={index}>{name}</li>
59
+ ))}
60
+ </ul>
61
+ ) : (
62
+ <span className={twMerge('text-xs', fileClassName)}>
63
+ {t('common.file_input.no_file')}
64
+ </span>
65
+ )}
66
+ </div>
67
+ </div>
68
+ );
7
69
  }
8
70
  );
@@ -21,3 +21,4 @@ export * from './link';
21
21
  export * from './pagination';
22
22
  export * from './live-commerce';
23
23
  export * from './file-input';
24
+ export * from './logger-popup';
@@ -40,7 +40,7 @@ export const Input = forwardRef<
40
40
  const inputClass = twMerge(
41
41
  clsx(
42
42
  'text-xs border px-2.5 h-10 placeholder:text-gray-600 peer',
43
- 'focus-visible:outline-hidden', // disable outline on focus
43
+ 'focus-visible:outline-none', // disable outline on focus
44
44
  error
45
45
  ? 'border-error focus:border-error'
46
46
  : 'border-gray-500 hover:border-black focus:border-black'
@@ -112,7 +112,7 @@ export const Input = forwardRef<
112
112
  )}
113
113
  </div>
114
114
  {error && (
115
- <span className="mt-1 text-sm text-error">{error.message}</span>
115
+ <span className="mt-1 text-sm text-error">{String(error.message)}</span>
116
116
  )}
117
117
  </div>
118
118
  );
@@ -19,26 +19,56 @@ export const Link = ({ children, href, ...rest }: LinkProps) => {
19
19
  return '#';
20
20
  }
21
21
 
22
- if (
23
- typeof href !== 'string' ||
24
- urlSchemes.some((scheme) => href.startsWith(scheme))
25
- ) {
26
- return href;
27
- }
22
+ if (typeof href === 'string') {
23
+ const trimmedHref = href.trim();
24
+ if (!trimmedHref) {
25
+ return '#';
26
+ }
27
+
28
+ if (urlSchemes.some((scheme) => trimmedHref.startsWith(scheme))) {
29
+ if (
30
+ trimmedHref.startsWith('mailto:') ||
31
+ trimmedHref.startsWith('tel:')
32
+ ) {
33
+ return trimmedHref;
34
+ }
35
+
36
+ try {
37
+ new URL(trimmedHref);
38
+ return trimmedHref;
39
+ } catch {
40
+ return '#';
41
+ }
42
+ }
28
43
 
29
- const pathnameWithoutLocale = href.replace(urlLocaleMatcherRegex, '');
30
- const hrefWithLocale = `/${locale}${pathnameWithoutLocale}`;
44
+ try {
45
+ new URL(trimmedHref, 'http://localhost');
46
+ } catch {
47
+ return '#';
48
+ }
31
49
 
32
- if (localeUrlStrategy === LocaleUrlStrategy.ShowAllLocales) {
33
- return hrefWithLocale;
34
- } else if (
35
- localeUrlStrategy === LocaleUrlStrategy.HideDefaultLocale &&
36
- locale !== defaultLocaleValue
37
- ) {
38
- return hrefWithLocale;
50
+ const pathnameWithoutLocale = trimmedHref.replace(
51
+ urlLocaleMatcherRegex,
52
+ ''
53
+ );
54
+ const hrefWithLocale = `/${locale}${pathnameWithoutLocale}`;
55
+
56
+ if (localeUrlStrategy === LocaleUrlStrategy.ShowAllLocales) {
57
+ return hrefWithLocale;
58
+ } else if (
59
+ localeUrlStrategy === LocaleUrlStrategy.HideDefaultLocale &&
60
+ locale !== defaultLocaleValue
61
+ ) {
62
+ return hrefWithLocale;
63
+ }
64
+
65
+ return trimmedHref || '#';
39
66
  }
40
67
 
41
- return href || '#';
68
+ if (typeof href !== 'string') {
69
+ return href;
70
+ }
71
+ return '#';
42
72
  }, [href, defaultLocaleValue, locale, localeUrlStrategy]);
43
73
 
44
74
  return (
@@ -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
+ };
@@ -4,16 +4,7 @@ import { ReactPortal } from './react-portal';
4
4
  import { Icon } from './icon';
5
5
  import { twMerge } from 'tailwind-merge';
6
6
  import { useEffect } from 'react';
7
-
8
- export interface ModalProps {
9
- portalId: string;
10
- children?: React.ReactNode;
11
- open?: boolean;
12
- setOpen?: (open: boolean) => void;
13
- title?: React.ReactNode;
14
- showCloseButton?: React.ReactNode;
15
- className?: string;
16
- }
7
+ import { ModalProps } from '../types';
17
8
 
18
9
  export const Modal = (props: ModalProps) => {
19
10
  const {
@@ -23,7 +14,14 @@ export const Modal = (props: ModalProps) => {
23
14
  setOpen,
24
15
  title = '',
25
16
  showCloseButton = true,
26
- className
17
+ className,
18
+ overlayClassName,
19
+ headerWrapperClassName,
20
+ titleClassName,
21
+ closeButtonClassName,
22
+ iconName = 'close',
23
+ iconSize = 16,
24
+ iconClassName
27
25
  } = props;
28
26
 
29
27
  useEffect(() => {
@@ -38,7 +36,12 @@ export const Modal = (props: ModalProps) => {
38
36
 
39
37
  return (
40
38
  <ReactPortal wrapperId={portalId}>
41
- <div className="fixed top-0 left-0 w-screen h-screen bg-primary/60 z-50" />
39
+ <div
40
+ className={twMerge(
41
+ 'fixed top-0 left-0 w-screen h-screen bg-primary bg-opacity-60 z-50',
42
+ overlayClassName
43
+ )}
44
+ />
42
45
  <section
43
46
  className={twMerge(
44
47
  'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 bg-white',
@@ -46,15 +49,28 @@ export const Modal = (props: ModalProps) => {
46
49
  )}
47
50
  >
48
51
  {(showCloseButton || title) && (
49
- <div className="flex px-6 py-4 border-b border-gray-400">
50
- {title && <h3 className="text-lg font-light">{title}</h3>}
52
+ <div
53
+ className={twMerge(
54
+ 'flex px-6 py-4 border-b border-gray-400',
55
+ headerWrapperClassName
56
+ )}
57
+ >
58
+ {title && (
59
+ <h3 className={twMerge('text-lg font-light', titleClassName)}>
60
+ {title}
61
+ </h3>
62
+ )}
51
63
  {showCloseButton && (
52
64
  <button
53
65
  type="button"
54
66
  onClick={() => setOpen(false)}
55
- className="ml-auto"
67
+ className={twMerge('ml-auto', closeButtonClassName)}
56
68
  >
57
- <Icon name="close" size={16} />
69
+ <Icon
70
+ name={iconName}
71
+ size={iconSize}
72
+ className={iconClassName}
73
+ />
58
74
  </button>
59
75
  )}
60
76
  </div>