@redacto.io/consent-sdk-react 7.3.0 → 8.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 (26) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/chunk-6A665OPR.mjs +30 -0
  3. package/dist/index.mjs +85 -108
  4. package/dist/privacy-center.js +3517 -3281
  5. package/dist/privacy-center.mjs +3519 -3295
  6. package/package.json +1 -1
  7. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +3 -3
  8. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +204 -0
  9. package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +218 -0
  10. package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +205 -0
  11. package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +107 -0
  12. package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +100 -0
  13. package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +338 -0
  14. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +222 -0
  15. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +28 -0
  16. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +40 -0
  17. package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +62 -0
  18. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +5 -27
  19. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -19
  20. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +42 -5
  21. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +13 -2
  22. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +0 -350
  23. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +0 -489
  24. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +0 -408
  25. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +0 -1
  26. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +0 -38
@@ -0,0 +1,222 @@
1
+ import { useCallback, useEffect, useState, type CSSProperties } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ArrowLeft, ChevronDown, LogOut } from 'lucide-react';
4
+ import { PCAvatar, PCPopover } from '../../ui';
5
+ import { useBranding } from '../../context/BrandingContext';
6
+ import { useOrganization } from '../../context/OrganizationContext';
7
+ import { getVar, signOutPrivacyCenter } from '../../lib/utils';
8
+
9
+ type UserMenuProps = {
10
+ onBack?: 'back' | 'signout';
11
+ };
12
+
13
+ function readEmail(): string {
14
+ if (typeof window === 'undefined') return '';
15
+ try {
16
+ return window.sessionStorage.getItem('email') ?? '';
17
+ } catch {
18
+ return '';
19
+ }
20
+ }
21
+
22
+ function initialsFromEmail(email: string): string {
23
+ if (!email) return '?';
24
+ const handle = email.split('@')[0] ?? '';
25
+ const parts = handle.split(/[._-]+/).filter(Boolean);
26
+ if (parts.length >= 2) {
27
+ return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
28
+ }
29
+ return (handle[0] ?? '?').toUpperCase();
30
+ }
31
+
32
+ export function UserMenu({ onBack }: UserMenuProps) {
33
+ const { t } = useTranslation();
34
+ const branding = useBranding();
35
+ const { organization } = useOrganization();
36
+ const [email, setEmail] = useState('');
37
+
38
+ useEffect(() => {
39
+ setEmail(readEmail());
40
+ }, []);
41
+
42
+ const handleAction = useCallback(() => {
43
+ if (onBack === 'back') {
44
+ window.history.back();
45
+ } else if (onBack === 'signout') {
46
+ signOutPrivacyCenter();
47
+ window.history.back();
48
+ }
49
+ }, [onBack]);
50
+
51
+ const actionLabel = onBack === 'back' ? t('back') : t('signOut');
52
+ const ActionIcon = onBack === 'back' ? ArrowLeft : LogOut;
53
+
54
+ const displayName = branding?.brand_name || organization?.name || '';
55
+ const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
56
+
57
+ const triggerStyle: CSSProperties = {
58
+ display: 'inline-flex',
59
+ alignItems: 'center',
60
+ gap: 6,
61
+ padding: '4px 6px 4px 4px',
62
+ borderRadius: 9999,
63
+ background: 'transparent',
64
+ border: `1px solid ${getVar('--pc-border-color')}`,
65
+ color: getVar('--pc-text-secondary'),
66
+ cursor: 'pointer',
67
+ transition: 'background 150ms ease, border-color 150ms ease, box-shadow 150ms ease',
68
+ };
69
+
70
+ const trigger = (
71
+ <div
72
+ style={triggerStyle}
73
+ role="presentation"
74
+ onMouseEnter={(e) => {
75
+ (e.currentTarget as HTMLDivElement).style.boxShadow =
76
+ '0 0 0 2px color-mix(in srgb, var(--pc-primary) 18%, transparent)';
77
+ }}
78
+ onMouseLeave={(e) => {
79
+ (e.currentTarget as HTMLDivElement).style.boxShadow = 'none';
80
+ }}
81
+ >
82
+ {branding?.icon_url ? (
83
+ <PCAvatar src={branding.icon_url} name={triggerAvatarName} size="sm" color="info" />
84
+ ) : (
85
+ <PCAvatar name={triggerAvatarName} size="sm" color="info" />
86
+ )}
87
+ <ChevronDown size={14} strokeWidth={2} />
88
+ </div>
89
+ );
90
+
91
+ const content = (
92
+ <div
93
+ role="menu"
94
+ style={{
95
+ minWidth: 280,
96
+ maxWidth: 320,
97
+ padding: 0,
98
+ background: getVar('--pc-bg-primary'),
99
+ }}
100
+ >
101
+ {/* Branded gradient header */}
102
+ <div
103
+ className="pc-shell-user-menu-header"
104
+ style={{
105
+ padding: '14px 18px',
106
+ display: 'flex',
107
+ flexDirection: 'column',
108
+ gap: 4,
109
+ borderBottom: `1px solid ${getVar('--pc-border-color')}`,
110
+ }}
111
+ >
112
+ <div
113
+ style={{
114
+ fontSize: 10,
115
+ fontWeight: 600,
116
+ letterSpacing: '0.1em',
117
+ textTransform: 'uppercase',
118
+ color: getVar('--pc-text-secondary'),
119
+ lineHeight: 1.2,
120
+ opacity: 0.85,
121
+ }}
122
+ >
123
+ Privacy Center
124
+ </div>
125
+ <div
126
+ title={displayName}
127
+ style={{
128
+ fontSize: 16,
129
+ fontWeight: 600,
130
+ letterSpacing: '-0.01em',
131
+ color: getVar('--pc-text-primary'),
132
+ lineHeight: 1.25,
133
+ overflow: 'hidden',
134
+ textOverflow: 'ellipsis',
135
+ whiteSpace: 'nowrap',
136
+ }}
137
+ >
138
+ {displayName || '—'}
139
+ </div>
140
+ </div>
141
+
142
+ {/* Identity card */}
143
+ {email && (
144
+ <div
145
+ style={{
146
+ padding: '12px 16px',
147
+ display: 'flex',
148
+ alignItems: 'center',
149
+ gap: 10,
150
+ borderBottom: onBack ? `1px solid ${getVar('--pc-border-color')}` : 'none',
151
+ }}
152
+ >
153
+ <PCAvatar name={initialsFromEmail(email)} size="sm" color="info" />
154
+ <div
155
+ title={email}
156
+ style={{
157
+ fontSize: 13,
158
+ fontWeight: 600,
159
+ color: getVar('--pc-text-primary'),
160
+ lineHeight: 1.3,
161
+ overflow: 'hidden',
162
+ textOverflow: 'ellipsis',
163
+ whiteSpace: 'nowrap',
164
+ minWidth: 0,
165
+ flex: 1,
166
+ }}
167
+ >
168
+ {email}
169
+ </div>
170
+ </div>
171
+ )}
172
+
173
+ {/* Action row */}
174
+ {onBack && (
175
+ <div style={{ padding: '6px 8px 8px' }}>
176
+ <button
177
+ type="button"
178
+ role="menuitem"
179
+ onClick={handleAction}
180
+ style={{
181
+ display: 'flex',
182
+ alignItems: 'center',
183
+ gap: 10,
184
+ width: '100%',
185
+ padding: '10px 14px',
186
+ border: 'none',
187
+ borderRadius: 8,
188
+ background: 'transparent',
189
+ color: getVar('--pc-text-primary'),
190
+ cursor: 'pointer',
191
+ fontFamily: 'inherit',
192
+ fontSize: 14,
193
+ fontWeight: 500,
194
+ textAlign: 'left',
195
+ transition: 'background 150ms ease',
196
+ }}
197
+ onMouseEnter={(e) => {
198
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
199
+ }}
200
+ onMouseLeave={(e) => {
201
+ e.currentTarget.style.background = 'transparent';
202
+ }}
203
+ >
204
+ <ActionIcon size={16} strokeWidth={2} />
205
+ <span>{actionLabel}</span>
206
+ </button>
207
+ </div>
208
+ )}
209
+ </div>
210
+ );
211
+
212
+ return (
213
+ <PCPopover
214
+ placement="bottom-end"
215
+ shadow="md"
216
+ className="pc-shell-user-menu-popover"
217
+ content={content}
218
+ >
219
+ {trigger}
220
+ </PCPopover>
221
+ );
222
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect, useState, type RefObject } from 'react';
2
+
3
+ export function useIsTruncated<T extends HTMLElement>(ref: RefObject<T>): boolean {
4
+ const [truncated, setTruncated] = useState(false);
5
+
6
+ useEffect(() => {
7
+ const el = ref.current;
8
+ if (!el) return;
9
+
10
+ const check = () => {
11
+ setTruncated(el.scrollWidth > el.clientWidth + 1);
12
+ };
13
+
14
+ check();
15
+
16
+ if (typeof ResizeObserver === 'undefined') {
17
+ const onResize = () => check();
18
+ window.addEventListener('resize', onResize);
19
+ return () => window.removeEventListener('resize', onResize);
20
+ }
21
+
22
+ const ro = new ResizeObserver(check);
23
+ ro.observe(el);
24
+ return () => ro.disconnect();
25
+ }, [ref]);
26
+
27
+ return truncated;
28
+ }
@@ -0,0 +1,40 @@
1
+ import { useEffect } from 'react';
2
+
3
+ export type ShortcutCombo = {
4
+ key: string;
5
+ /** When true, requires Cmd (mac) or Ctrl (win/linux). */
6
+ mod?: boolean;
7
+ shift?: boolean;
8
+ alt?: boolean;
9
+ };
10
+
11
+ function isEditableTarget(target: EventTarget | null): boolean {
12
+ if (!target || !(target instanceof HTMLElement)) return false;
13
+ const tag = target.tagName;
14
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
15
+ if (target.isContentEditable) return true;
16
+ return false;
17
+ }
18
+
19
+ export function useKeyboardShortcut(combo: ShortcutCombo, handler: () => void) {
20
+ useEffect(() => {
21
+ if (typeof window === 'undefined') return;
22
+
23
+ const onKey = (event: KeyboardEvent) => {
24
+ if (event.key.toLowerCase() !== combo.key.toLowerCase()) return;
25
+ if (combo.mod && !(event.metaKey || event.ctrlKey)) return;
26
+ if (!combo.mod && (event.metaKey || event.ctrlKey)) return;
27
+ if (combo.shift && !event.shiftKey) return;
28
+ if (!combo.shift && event.shiftKey) return;
29
+ if (combo.alt && !event.altKey) return;
30
+ if (!combo.alt && event.altKey) return;
31
+ if (isEditableTarget(event.target)) return;
32
+
33
+ event.preventDefault();
34
+ handler();
35
+ };
36
+
37
+ window.addEventListener('keydown', onKey);
38
+ return () => window.removeEventListener('keydown', onKey);
39
+ }, [combo.key, combo.mod, combo.shift, combo.alt, handler]);
40
+ }
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ export type SidebarState = 'expanded' | 'collapsed';
4
+
5
+ const STORAGE_KEY = 'redacto-pc-sidebar-state';
6
+ const EXPAND_MIN_WIDTH = '(min-width: 1024px)';
7
+
8
+ function getDefaultState(): SidebarState {
9
+ if (typeof window === 'undefined') return 'collapsed';
10
+ try {
11
+ return window.matchMedia(EXPAND_MIN_WIDTH).matches ? 'expanded' : 'collapsed';
12
+ } catch {
13
+ return 'collapsed';
14
+ }
15
+ }
16
+
17
+ function readStored(): SidebarState | null {
18
+ if (typeof window === 'undefined') return null;
19
+ try {
20
+ const value = window.localStorage.getItem(STORAGE_KEY);
21
+ return value === 'expanded' || value === 'collapsed' ? value : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function writeStored(value: SidebarState) {
28
+ if (typeof window === 'undefined') return;
29
+ try {
30
+ window.localStorage.setItem(STORAGE_KEY, value);
31
+ } catch {
32
+ // localStorage unavailable / quota exceeded — ignore
33
+ }
34
+ }
35
+
36
+ export function useSidebarState() {
37
+ const [state, setStateRaw] = useState<SidebarState | undefined>(undefined);
38
+
39
+ useEffect(() => {
40
+ setStateRaw(readStored() ?? getDefaultState());
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (state) writeStored(state);
45
+ }, [state]);
46
+
47
+ const setState = useCallback((next: SidebarState) => {
48
+ setStateRaw(next);
49
+ }, []);
50
+
51
+ const toggle = useCallback(() => {
52
+ setStateRaw((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
53
+ }, []);
54
+
55
+ return {
56
+ state: state ?? 'collapsed',
57
+ isHydrated: state !== undefined,
58
+ isExpanded: state === 'expanded',
59
+ setState,
60
+ toggle,
61
+ };
62
+ }
@@ -1,42 +1,20 @@
1
- import {
2
- IoAlertCircleOutline,
3
- IoPulseOutline,
4
- IoShieldCheckmarkOutline,
5
- } from 'react-icons/io5';
6
- import type { BadgeType, MenuItem } from './types';
1
+ import { Activity, MessageCircleWarning, ShieldCheck } from 'lucide-react';
2
+ import type { MenuItem } from './types';
7
3
 
8
4
  export const menuItemsPC: MenuItem[] = [
9
5
  {
10
6
  name: 'Consent Manager',
11
7
  href: 'consent-manager',
12
- icon: <IoShieldCheckmarkOutline size={20} />,
8
+ icon: <ShieldCheck size={18} strokeWidth={1.75} />,
13
9
  },
14
10
  {
15
11
  name: 'Grievance',
16
12
  href: 'form',
17
- icon: <IoAlertCircleOutline size={20} />,
13
+ icon: <MessageCircleWarning size={18} strokeWidth={1.75} />,
18
14
  },
19
15
  {
20
16
  name: 'Activity',
21
17
  href: 'activity',
22
- icon: <IoPulseOutline size={20} />,
18
+ icon: <Activity size={18} strokeWidth={1.75} />,
23
19
  },
24
20
  ];
25
-
26
- export const badges: Record<BadgeType, { text: string; className: string }> = {
27
- new: {
28
- text: 'New',
29
- className: 'bg-blue-100 text-blue-500',
30
- },
31
- coming: {
32
- text: '',
33
- className: 'visibility-hidden',
34
- },
35
- };
36
-
37
- export const LOCAL_STORAGE_KEYS_TO_REMOVE = [
38
- 'email',
39
- 'access_token',
40
- 'i18nextLng',
41
- 'lang',
42
- ] as const;
@@ -1,12 +1,5 @@
1
1
  import type { ReactElement } from 'react';
2
2
 
3
- export type BadgeType = 'new' | 'coming';
4
-
5
- export type MenuBadgeProps = {
6
- type: BadgeType;
7
- isDisabled?: boolean;
8
- };
9
-
10
3
  export type DropdownItem = {
11
4
  name: string;
12
5
  href: string;
@@ -26,15 +19,3 @@ export type MenuItem = {
26
19
  badge?: string;
27
20
  dropdownItems?: DropdownItem[];
28
21
  };
29
-
30
- export type SideMenuPCProps = {
31
- menuItems: MenuItem[];
32
- isCollapsed: boolean;
33
- onItemSelect?: () => void;
34
- };
35
-
36
- export type SideBarPCProps = {
37
- className?: string;
38
- isCompact?: boolean;
39
- onBack?: 'back' | 'signout';
40
- };
@@ -77,7 +77,7 @@ export const injectPCStyles = () => {
77
77
  --pc-spacing-lg: 1.25rem;
78
78
  --pc-spacing-xl: 1.75rem;
79
79
  --pc-spacing-2xl: 2.5rem;
80
- --pc-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
80
+ --pc-font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
81
81
  --pc-font-size-xs: 0.75rem;
82
82
  --pc-font-size-sm: 0.8125rem;
83
83
  --pc-font-size-base: 0.9375rem;
@@ -111,6 +111,30 @@ export const injectPCStyles = () => {
111
111
  document.head.appendChild(variablesStyle);
112
112
  }
113
113
 
114
+ // Inject Inter font from Google Fonts so the SDK has a polished default
115
+ // even when the host app provides no custom typography. Hosts can opt out
116
+ // by overriding --pc-font-family.
117
+ const FONT_LINK_ID = 'redacto-privacy-center-fonts';
118
+ if (!document.getElementById(FONT_LINK_ID)) {
119
+ const preconnect1 = document.createElement('link');
120
+ preconnect1.rel = 'preconnect';
121
+ preconnect1.href = 'https://fonts.googleapis.com';
122
+ document.head.appendChild(preconnect1);
123
+
124
+ const preconnect2 = document.createElement('link');
125
+ preconnect2.rel = 'preconnect';
126
+ preconnect2.href = 'https://fonts.gstatic.com';
127
+ preconnect2.crossOrigin = 'anonymous';
128
+ document.head.appendChild(preconnect2);
129
+
130
+ const fontLink = document.createElement('link');
131
+ fontLink.id = FONT_LINK_ID;
132
+ fontLink.rel = 'stylesheet';
133
+ fontLink.href =
134
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
135
+ document.head.appendChild(fontLink);
136
+ }
137
+
114
138
  style.textContent = `
115
139
  /* Privacy Center SDK Styles */
116
140
 
@@ -315,6 +339,17 @@ export const injectPCStyles = () => {
315
339
  .pc-select-dropdown {
316
340
  animation: pc-slideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) !important;
317
341
  }
342
+
343
+ /* Popover Animation - matches modal/dropdown motion vocabulary */
344
+ .pc-popover {
345
+ animation: pc-fadeIn 120ms cubic-bezier(0.16, 1, 0.3, 1);
346
+ }
347
+
348
+ @media (prefers-reduced-motion: reduce) {
349
+ .pc-popover {
350
+ animation: none !important;
351
+ }
352
+ }
318
353
 
319
354
  /* Responsive dropdown - allow width to be set via inline styles */
320
355
  .pc-select-dropdown {
@@ -2299,7 +2334,7 @@ export const injectPCStyles = () => {
2299
2334
  border: 1px solid var(--pc-border-color);
2300
2335
  background: var(--pc-bg-primary);
2301
2336
  cursor: pointer;
2302
- border-radius: 0.5rem;
2337
+ border-radius: 9999px;
2303
2338
  color: var(--pc-text-primary);
2304
2339
  transition: color 0.3s ease, background-color 0.15s ease, border-color 0.15s ease;
2305
2340
  padding: 0;
@@ -2458,12 +2493,14 @@ export const injectPCStyles = () => {
2458
2493
  position: relative;
2459
2494
  }
2460
2495
 
2461
- .pc-notification-popover-offset {
2462
- transform: translateX(-65px) !important;
2496
+ @media (min-width: 640px) {
2497
+ .pc-notification-popover-offset {
2498
+ transform: translateX(-65px) !important;
2499
+ }
2463
2500
  }
2464
2501
 
2465
2502
  .pc-notification-dropdown {
2466
- width: 380px;
2503
+ width: min(380px, calc(100vw - 16px));
2467
2504
  max-height: 480px;
2468
2505
  display: flex;
2469
2506
  flex-direction: column;
@@ -1,6 +1,17 @@
1
- import { type ReactNode, type CSSProperties, useState, useRef, useEffect } from 'react';
1
+ import {
2
+ type ReactNode,
3
+ type CSSProperties,
4
+ useState,
5
+ useRef,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ } from 'react';
2
9
  import { pcStyles } from '../styles/pcStyles';
3
10
 
11
+ // useLayoutEffect warns under SSR; fall back to useEffect when window is absent.
12
+ const useIsomorphicLayoutEffect =
13
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
14
+
4
15
  export interface PCPopoverProps {
5
16
  content: ReactNode;
6
17
  children: ReactNode;
@@ -88,7 +99,7 @@ export function PCPopover({
88
99
  setPosition({ top, left });
89
100
  };
90
101
 
91
- useEffect(() => {
102
+ useIsomorphicLayoutEffect(() => {
92
103
  if (isOpen) {
93
104
  updatePosition();
94
105
  window.addEventListener('scroll', updatePosition, true);