@redacto.io/consent-sdk-react 7.2.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.
- package/CHANGELOG.md +12 -0
- package/dist/chunk-6A665OPR.mjs +30 -0
- package/dist/index.mjs +85 -108
- package/dist/privacy-center.js +3527 -3285
- package/dist/privacy-center.mjs +3530 -3300
- package/package.json +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +3 -3
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +1 -1
- package/src/RedactoPrivacyCenter/api/orgBranding.ts +10 -3
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +204 -0
- package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +218 -0
- package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +205 -0
- package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +107 -0
- package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +100 -0
- package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +338 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +222 -0
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +40 -0
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +62 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +5 -27
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -19
- package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +11 -3
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +42 -5
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +13 -2
- package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +0 -350
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +0 -489
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +0 -408
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +0 -1
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +0 -38
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ import { useBaseUrl } from './context/BaseUrlContext';
|
|
|
9
9
|
import { useAuth } from './context/AuthContext';
|
|
10
10
|
import { PCLoader } from './ui';
|
|
11
11
|
import { pcStyles } from './styles/pcStyles';
|
|
12
|
-
import
|
|
12
|
+
import AppShell from './components/AppShell/AppShell';
|
|
13
13
|
|
|
14
14
|
interface PrivacyCenterLayoutProps {
|
|
15
15
|
children: ReactNode;
|
|
@@ -161,9 +161,9 @@ export function PrivacyCenterLayout({ children, slug, onBack }: PrivacyCenterLay
|
|
|
161
161
|
|
|
162
162
|
return (
|
|
163
163
|
<OrganizationProvider organization={organization}>
|
|
164
|
-
<
|
|
164
|
+
<AppShell onBack={onBack}>
|
|
165
165
|
{children}
|
|
166
|
-
</
|
|
166
|
+
</AppShell>
|
|
167
167
|
</OrganizationProvider>
|
|
168
168
|
);
|
|
169
169
|
}
|
|
@@ -34,7 +34,7 @@ export function RedactoPrivacyCenter({
|
|
|
34
34
|
}: RedactoPrivacyCenterProps) {
|
|
35
35
|
return (
|
|
36
36
|
<I18nextProvider i18n={i18n}>
|
|
37
|
-
<BrandingProvider slug={slug}>
|
|
37
|
+
<BrandingProvider slug={slug} baseUrl={baseUrl}>
|
|
38
38
|
<BaseUrlProvider baseUrl={baseUrl}>
|
|
39
39
|
<AuthProvider
|
|
40
40
|
accessToken={accessToken}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { apiFetch } from './fetcher';
|
|
2
2
|
import type { OrgBrandingDetail } from '../lib/types';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
function getUserServerBaseUrl(baseUrl: string): string {
|
|
5
|
+
return baseUrl.includes('.io')
|
|
6
|
+
? 'https://api.redacto.io/user/api/0'
|
|
7
|
+
: 'https://api.redacto.tech/user/api/0';
|
|
8
|
+
}
|
|
5
9
|
|
|
6
|
-
export async function fetchOrgBranding(
|
|
7
|
-
|
|
10
|
+
export async function fetchOrgBranding(
|
|
11
|
+
slug: string,
|
|
12
|
+
baseUrl: string,
|
|
13
|
+
): Promise<OrgBrandingDetail> {
|
|
14
|
+
const url = `${getUserServerBaseUrl(baseUrl)}/public/org-branding/by-slug/${encodeURIComponent(slug)}`;
|
|
8
15
|
return apiFetch<OrgBrandingDetail>(url, { method: 'GET' });
|
|
9
16
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Suspense,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { LocaleContext } from '../../context/LocaleContext';
|
|
11
|
+
import { pcStyles } from '../../styles/pcStyles';
|
|
12
|
+
import { getVar } from '../../lib/utils';
|
|
13
|
+
import Footer from '../../ui/Footer';
|
|
14
|
+
import i18n from '../../lib/i18n';
|
|
15
|
+
import { useMediaQuery } from '../../../RedactoNoticeConsent/useMediaQuery';
|
|
16
|
+
import { useSidebarState } from './hooks/useSidebarState';
|
|
17
|
+
import { useKeyboardShortcut } from './hooks/useKeyboardShortcut';
|
|
18
|
+
import { TopAppBar } from './TopAppBar';
|
|
19
|
+
import { SidebarNav } from './SidebarNav';
|
|
20
|
+
import { MobileNavDrawer } from './MobileNavDrawer';
|
|
21
|
+
import { PageHeader } from './PageHeader';
|
|
22
|
+
import type { PrivacyCenterLayoutProps } from '../types';
|
|
23
|
+
|
|
24
|
+
export default function AppShell({
|
|
25
|
+
children,
|
|
26
|
+
sidebarClassName,
|
|
27
|
+
contentClassName,
|
|
28
|
+
onBack,
|
|
29
|
+
}: PrivacyCenterLayoutProps) {
|
|
30
|
+
const { t } = useTranslation();
|
|
31
|
+
const sidebar = useSidebarState();
|
|
32
|
+
const [locale, setLocale] = useState(i18n.language);
|
|
33
|
+
const [isMobileNavOpen, setMobileNavOpen] = useState(false);
|
|
34
|
+
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
|
35
|
+
|
|
36
|
+
const isMobile = useMediaQuery('(max-width: 639px)');
|
|
37
|
+
|
|
38
|
+
// Close mobile drawer when crossing the breakpoint
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!isMobile && isMobileNavOpen) setMobileNavOpen(false);
|
|
41
|
+
}, [isMobile, isMobileNavOpen]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const onChange = () => setLocale(i18n.language);
|
|
45
|
+
i18n.on('languageChanged', onChange);
|
|
46
|
+
return () => i18n.off('languageChanged', onChange);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handleSidebarToggle = useCallback(() => {
|
|
50
|
+
sidebar.toggle();
|
|
51
|
+
}, [sidebar]);
|
|
52
|
+
|
|
53
|
+
// Cmd/Ctrl+B toggles sidebar (or mobile drawer on small screens)
|
|
54
|
+
useKeyboardShortcut(
|
|
55
|
+
{ key: 'b', mod: true },
|
|
56
|
+
useCallback(() => {
|
|
57
|
+
if (isMobile) setMobileNavOpen((prev) => !prev);
|
|
58
|
+
else sidebar.toggle();
|
|
59
|
+
}, [isMobile, sidebar]),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const rootStyle: CSSProperties = {
|
|
63
|
+
display: 'flex',
|
|
64
|
+
flexDirection: 'column',
|
|
65
|
+
minHeight: '100vh',
|
|
66
|
+
background: getVar('--pc-bg-shell'),
|
|
67
|
+
position: 'relative',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const bodyStyle: CSSProperties = {
|
|
71
|
+
display: 'flex',
|
|
72
|
+
flex: 1,
|
|
73
|
+
minHeight: 0,
|
|
74
|
+
position: 'relative',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const mainStyle: CSSProperties = {
|
|
78
|
+
flex: 1,
|
|
79
|
+
minWidth: 0,
|
|
80
|
+
display: 'flex',
|
|
81
|
+
flexDirection: 'column',
|
|
82
|
+
background: getVar('--pc-bg-shell'),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const contentWrapperStyle: CSSProperties = {
|
|
86
|
+
flex: 1,
|
|
87
|
+
display: 'flex',
|
|
88
|
+
flexDirection: 'column',
|
|
89
|
+
minHeight: 0,
|
|
90
|
+
padding: 'var(--pc-shell-content-padding, 0 24px)',
|
|
91
|
+
boxSizing: 'border-box',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<LocaleContext.Provider value={{ locale, setLocale }}>
|
|
96
|
+
<Suspense
|
|
97
|
+
fallback={
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
...pcStyles.flex,
|
|
101
|
+
...pcStyles.itemsCenter,
|
|
102
|
+
...pcStyles.justifyCenter,
|
|
103
|
+
minHeight: '100vh',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{t('loadingEllipsis')}
|
|
107
|
+
</div>
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<main className="pc-root pc-shell-root" style={rootStyle}>
|
|
111
|
+
<TopAppBar
|
|
112
|
+
ref={hamburgerRef}
|
|
113
|
+
showHamburger={isMobile}
|
|
114
|
+
onOpenMobileNav={() => setMobileNavOpen(true)}
|
|
115
|
+
locale={locale}
|
|
116
|
+
onLocaleChange={setLocale}
|
|
117
|
+
onBack={onBack}
|
|
118
|
+
/>
|
|
119
|
+
<div className="pc-shell-body" style={bodyStyle}>
|
|
120
|
+
{!isMobile && (
|
|
121
|
+
<SidebarNav
|
|
122
|
+
className={sidebarClassName}
|
|
123
|
+
isExpanded={sidebar.isExpanded}
|
|
124
|
+
onToggle={handleSidebarToggle}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
<section className="pc-shell-content" style={mainStyle}>
|
|
128
|
+
<div className={contentClassName} style={contentWrapperStyle}>
|
|
129
|
+
<PageHeader />
|
|
130
|
+
<div style={{ flex: 1, minWidth: 0, width: '100%' }}>{children}</div>
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
...pcStyles.flex,
|
|
134
|
+
width: '100%',
|
|
135
|
+
...pcStyles.itemsCenter,
|
|
136
|
+
...pcStyles.justifyCenter,
|
|
137
|
+
paddingTop: 12,
|
|
138
|
+
paddingBottom: 12,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
<Footer />
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
</div>
|
|
146
|
+
{isMobile && (
|
|
147
|
+
<MobileNavDrawer
|
|
148
|
+
isOpen={isMobileNavOpen}
|
|
149
|
+
onClose={() => setMobileNavOpen(false)}
|
|
150
|
+
returnFocusRef={hamburgerRef}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</main>
|
|
154
|
+
<style>{`
|
|
155
|
+
.pc-shell-sidebar {
|
|
156
|
+
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
157
|
+
}
|
|
158
|
+
.pc-shell-nav-item:focus-visible {
|
|
159
|
+
box-shadow: 0 0 0 2px var(--pc-border-color-focus);
|
|
160
|
+
}
|
|
161
|
+
.pc-shell-sidebar-toggle {
|
|
162
|
+
opacity: 0;
|
|
163
|
+
transition: opacity 200ms ease;
|
|
164
|
+
}
|
|
165
|
+
.pc-shell-sidebar:hover .pc-shell-sidebar-toggle,
|
|
166
|
+
.pc-shell-sidebar-toggle:hover,
|
|
167
|
+
.pc-shell-sidebar-toggle:focus-visible {
|
|
168
|
+
opacity: 1;
|
|
169
|
+
}
|
|
170
|
+
@media (prefers-reduced-motion: reduce) {
|
|
171
|
+
.pc-shell-sidebar-toggle { transition: none !important; }
|
|
172
|
+
}
|
|
173
|
+
.pc-shell-user-menu-header {
|
|
174
|
+
background: linear-gradient(135deg, var(--pc-bg-secondary) 0%, var(--pc-bg-shell) 100%);
|
|
175
|
+
background: linear-gradient(
|
|
176
|
+
135deg,
|
|
177
|
+
color-mix(in srgb, var(--pc-primary) 6%, var(--pc-bg-shell)) 0%,
|
|
178
|
+
var(--pc-bg-shell) 100%
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
.pc-popover.pc-shell-user-menu-popover {
|
|
182
|
+
border-radius: 12px !important;
|
|
183
|
+
overflow: hidden !important;
|
|
184
|
+
padding: 0 !important;
|
|
185
|
+
background: var(--pc-bg-primary) !important;
|
|
186
|
+
}
|
|
187
|
+
@media (prefers-reduced-motion: reduce) {
|
|
188
|
+
.pc-shell-sidebar { transition: none !important; }
|
|
189
|
+
.pc-shell-nav-item-icon { transform: none !important; transition: none !important; }
|
|
190
|
+
}
|
|
191
|
+
@media (max-width: 639px) {
|
|
192
|
+
:root { --pc-shell-content-padding: 0 12px; --pc-shell-pageheader-padding: 14px 0 12px; }
|
|
193
|
+
}
|
|
194
|
+
@media (min-width: 640px) and (max-width: 1023px) {
|
|
195
|
+
:root { --pc-shell-content-padding: 0 20px; --pc-shell-pageheader-padding: 18px 0 14px; }
|
|
196
|
+
}
|
|
197
|
+
@media (min-width: 1024px) {
|
|
198
|
+
:root { --pc-shell-content-padding: 0 32px; --pc-shell-pageheader-padding: 22px 0 16px; }
|
|
199
|
+
}
|
|
200
|
+
`}</style>
|
|
201
|
+
</Suspense>
|
|
202
|
+
</LocaleContext.Provider>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type RefObject,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
import { X } from 'lucide-react';
|
|
12
|
+
import { PCActionIcon, PCAvatar } from '../../ui';
|
|
13
|
+
import { useMenuItemsPC } from '../../lib/constants';
|
|
14
|
+
import { useBranding } from '../../context/BrandingContext';
|
|
15
|
+
import { useOrganization } from '../../context/OrganizationContext';
|
|
16
|
+
import { getVar } from '../../lib/utils';
|
|
17
|
+
import { NavItem } from './NavItem';
|
|
18
|
+
|
|
19
|
+
type MobileNavDrawerProps = {
|
|
20
|
+
isOpen: boolean;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
returnFocusRef?: RefObject<HTMLElement>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function MobileNavDrawer({ isOpen, onClose, returnFocusRef }: MobileNavDrawerProps) {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const menuItems = useMenuItemsPC();
|
|
28
|
+
const branding = useBranding();
|
|
29
|
+
const { organization } = useOrganization();
|
|
30
|
+
const displayName = branding?.brand_name || organization?.name || '';
|
|
31
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const firstNavRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
const flatNavItems = useMemo(
|
|
35
|
+
() => menuItems.filter((item) => Boolean(item.href)),
|
|
36
|
+
[menuItems],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// body scroll lock
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (typeof document === 'undefined') return;
|
|
42
|
+
if (!isOpen) return;
|
|
43
|
+
const previous = document.body.style.overflow;
|
|
44
|
+
document.body.style.overflow = 'hidden';
|
|
45
|
+
return () => {
|
|
46
|
+
document.body.style.overflow = previous;
|
|
47
|
+
};
|
|
48
|
+
}, [isOpen]);
|
|
49
|
+
|
|
50
|
+
// Esc to close + focus management
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isOpen || typeof window === 'undefined') return;
|
|
53
|
+
|
|
54
|
+
const onKey = (event: KeyboardEvent) => {
|
|
55
|
+
if (event.key === 'Escape') {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
onClose();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener('keydown', onKey);
|
|
61
|
+
|
|
62
|
+
// Move focus into drawer
|
|
63
|
+
const focusTimer = window.setTimeout(() => {
|
|
64
|
+
firstNavRef.current?.focus();
|
|
65
|
+
// If the wrapper isn't focusable, focus the first focusable child
|
|
66
|
+
const target = firstNavRef.current?.querySelector<HTMLElement>(
|
|
67
|
+
'button, [href], [tabindex]:not([tabindex="-1"])',
|
|
68
|
+
);
|
|
69
|
+
target?.focus();
|
|
70
|
+
}, 30);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener('keydown', onKey);
|
|
74
|
+
window.clearTimeout(focusTimer);
|
|
75
|
+
};
|
|
76
|
+
}, [isOpen, onClose]);
|
|
77
|
+
|
|
78
|
+
// return focus to trigger on close
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (isOpen) return;
|
|
81
|
+
if (typeof window === 'undefined') return;
|
|
82
|
+
const node = returnFocusRef?.current;
|
|
83
|
+
if (!node) return;
|
|
84
|
+
const t = window.setTimeout(() => node.focus(), 0);
|
|
85
|
+
return () => window.clearTimeout(t);
|
|
86
|
+
}, [isOpen, returnFocusRef]);
|
|
87
|
+
|
|
88
|
+
const handleNavigate = useCallback(() => {
|
|
89
|
+
onClose();
|
|
90
|
+
}, [onClose]);
|
|
91
|
+
|
|
92
|
+
const overlayStyle: CSSProperties = {
|
|
93
|
+
position: 'fixed',
|
|
94
|
+
inset: 0,
|
|
95
|
+
zIndex: 1190,
|
|
96
|
+
background: getVar('--pc-bg-overlay'),
|
|
97
|
+
opacity: isOpen ? 1 : 0,
|
|
98
|
+
pointerEvents: isOpen ? 'auto' : 'none',
|
|
99
|
+
transition: 'opacity 200ms ease',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const drawerStyle: CSSProperties = {
|
|
103
|
+
position: 'fixed',
|
|
104
|
+
top: 0,
|
|
105
|
+
left: 0,
|
|
106
|
+
bottom: 0,
|
|
107
|
+
width: 280,
|
|
108
|
+
maxWidth: '85vw',
|
|
109
|
+
zIndex: 1195,
|
|
110
|
+
background: getVar('--pc-bg-shell'),
|
|
111
|
+
borderRight: `1px solid ${getVar('--pc-border-color')}`,
|
|
112
|
+
boxShadow: '12px 0 32px rgba(15, 23, 42, 0.18)',
|
|
113
|
+
transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
|
|
114
|
+
transition: 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)',
|
|
115
|
+
display: 'flex',
|
|
116
|
+
flexDirection: 'column',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (typeof document === 'undefined') return null;
|
|
120
|
+
|
|
121
|
+
return createPortal(
|
|
122
|
+
<>
|
|
123
|
+
<div
|
|
124
|
+
style={overlayStyle}
|
|
125
|
+
onClick={onClose}
|
|
126
|
+
aria-hidden="true"
|
|
127
|
+
/>
|
|
128
|
+
<aside
|
|
129
|
+
ref={drawerRef}
|
|
130
|
+
role="dialog"
|
|
131
|
+
aria-modal="true"
|
|
132
|
+
aria-label={t('openSidebarMenu')}
|
|
133
|
+
aria-hidden={!isOpen}
|
|
134
|
+
style={drawerStyle}
|
|
135
|
+
>
|
|
136
|
+
<div
|
|
137
|
+
style={{
|
|
138
|
+
display: 'flex',
|
|
139
|
+
alignItems: 'center',
|
|
140
|
+
justifyContent: 'space-between',
|
|
141
|
+
gap: 12,
|
|
142
|
+
height: 56,
|
|
143
|
+
padding: '0 12px 0 16px',
|
|
144
|
+
borderBottom: `1px solid ${getVar('--pc-border-color')}`,
|
|
145
|
+
flexShrink: 0,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div
|
|
149
|
+
style={{
|
|
150
|
+
display: 'flex',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
minWidth: 0,
|
|
153
|
+
flex: 1,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{branding?.logo_url ? (
|
|
157
|
+
<img
|
|
158
|
+
src={branding.logo_url}
|
|
159
|
+
alt={displayName}
|
|
160
|
+
style={{
|
|
161
|
+
height: 36,
|
|
162
|
+
width: 'auto',
|
|
163
|
+
maxWidth: 160,
|
|
164
|
+
objectFit: 'contain',
|
|
165
|
+
display: 'block',
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
) : branding?.icon_url ? (
|
|
169
|
+
<PCAvatar src={branding.icon_url} name={displayName} size="md" color="info" />
|
|
170
|
+
) : (
|
|
171
|
+
<PCAvatar name={displayName || '?'} size="md" color="info" />
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
<PCActionIcon
|
|
175
|
+
variant="default"
|
|
176
|
+
aria-label={t('closeSidebarMenu')}
|
|
177
|
+
onClick={onClose}
|
|
178
|
+
style={{
|
|
179
|
+
width: 36,
|
|
180
|
+
height: 36,
|
|
181
|
+
padding: 0,
|
|
182
|
+
color: getVar('--pc-text-primary'),
|
|
183
|
+
flexShrink: 0,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<X size={20} />
|
|
187
|
+
</PCActionIcon>
|
|
188
|
+
</div>
|
|
189
|
+
<nav
|
|
190
|
+
role="navigation"
|
|
191
|
+
aria-label="Privacy center primary navigation"
|
|
192
|
+
ref={firstNavRef}
|
|
193
|
+
tabIndex={-1}
|
|
194
|
+
style={{
|
|
195
|
+
flex: 1,
|
|
196
|
+
overflowY: 'auto',
|
|
197
|
+
overflowX: 'hidden',
|
|
198
|
+
padding: '12px 12px 24px',
|
|
199
|
+
display: 'flex',
|
|
200
|
+
flexDirection: 'column',
|
|
201
|
+
gap: 4,
|
|
202
|
+
outline: 'none',
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{flatNavItems.map((item) => (
|
|
206
|
+
<NavItem
|
|
207
|
+
key={item.href ?? item.name}
|
|
208
|
+
item={item}
|
|
209
|
+
mode="expanded"
|
|
210
|
+
onNavigate={handleNavigate}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
213
|
+
</nav>
|
|
214
|
+
</aside>
|
|
215
|
+
</>,
|
|
216
|
+
document.body,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type KeyboardEvent } from 'react';
|
|
2
|
+
import { PCTooltip } from '../../ui';
|
|
3
|
+
import { useInternalNavigation, type InternalPage } from '../../context/NavigationContext';
|
|
4
|
+
import { getVar } from '../../lib/utils';
|
|
5
|
+
import type { MenuItem } from '../SideMenuBarPC/types';
|
|
6
|
+
|
|
7
|
+
type NavItemProps = {
|
|
8
|
+
item: MenuItem;
|
|
9
|
+
mode: 'expanded' | 'collapsed';
|
|
10
|
+
onNavigate?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function NavItem({ item, mode, onNavigate }: NavItemProps) {
|
|
14
|
+
const { currentPage, navigateTo } = useInternalNavigation();
|
|
15
|
+
const [hover, setHover] = useState(false);
|
|
16
|
+
|
|
17
|
+
const isCollapsed = mode === 'collapsed';
|
|
18
|
+
const isActive = item.href ? currentPage === (item.href as InternalPage) : false;
|
|
19
|
+
const isDisabled = !!item.isComingSoon;
|
|
20
|
+
const accent = getVar('--pc-primary');
|
|
21
|
+
|
|
22
|
+
const handleActivate = () => {
|
|
23
|
+
if (isDisabled || !item.href) return;
|
|
24
|
+
navigateTo(item.href as InternalPage);
|
|
25
|
+
onNavigate?.();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
29
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
handleActivate();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const labelColor = isDisabled
|
|
36
|
+
? getVar('--pc-text-disabled')
|
|
37
|
+
: isActive
|
|
38
|
+
? accent
|
|
39
|
+
: getVar('--pc-text-primary');
|
|
40
|
+
|
|
41
|
+
// Icon container styling — distinct from row, gives each item a tactile feel.
|
|
42
|
+
const containerBg = isDisabled
|
|
43
|
+
? 'transparent'
|
|
44
|
+
: isActive
|
|
45
|
+
? getVar('--pc-bg-tertiary')
|
|
46
|
+
: hover
|
|
47
|
+
? getVar('--pc-bg-tertiary')
|
|
48
|
+
: getVar('--pc-bg-secondary');
|
|
49
|
+
|
|
50
|
+
const containerColor = isDisabled
|
|
51
|
+
? getVar('--pc-text-disabled')
|
|
52
|
+
: isActive
|
|
53
|
+
? accent
|
|
54
|
+
: hover
|
|
55
|
+
? getVar('--pc-text-primary')
|
|
56
|
+
: getVar('--pc-text-secondary');
|
|
57
|
+
|
|
58
|
+
const containerTransform =
|
|
59
|
+
!isDisabled && !isActive && hover ? 'translateX(1px)' : 'translateX(0)';
|
|
60
|
+
|
|
61
|
+
const buttonStyle: CSSProperties = isCollapsed
|
|
62
|
+
? {
|
|
63
|
+
position: 'relative',
|
|
64
|
+
display: 'flex',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
width: 40,
|
|
68
|
+
height: 40,
|
|
69
|
+
padding: 0,
|
|
70
|
+
margin: 0,
|
|
71
|
+
background: 'transparent',
|
|
72
|
+
color: labelColor,
|
|
73
|
+
border: 'none',
|
|
74
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
75
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
76
|
+
outline: 'none',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
transition: 'background-color 150ms ease',
|
|
79
|
+
fontFamily: 'inherit',
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
position: 'relative',
|
|
83
|
+
display: 'flex',
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
width: '100%',
|
|
86
|
+
height: 44,
|
|
87
|
+
padding: '0 12px 0 14px',
|
|
88
|
+
gap: 10,
|
|
89
|
+
borderRadius: 10,
|
|
90
|
+
background: isActive ? getVar('--pc-bg-hover') : 'transparent',
|
|
91
|
+
color: labelColor,
|
|
92
|
+
border: 'none',
|
|
93
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
94
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
95
|
+
outline: 'none',
|
|
96
|
+
transition: 'background-color 150ms ease, color 150ms ease, box-shadow 150ms ease',
|
|
97
|
+
fontFamily: 'inherit',
|
|
98
|
+
fontSize: 14,
|
|
99
|
+
fontWeight: isActive ? 600 : 500,
|
|
100
|
+
lineHeight: 1,
|
|
101
|
+
textAlign: 'left',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const accentBar = isActive && !isCollapsed ? (
|
|
105
|
+
<span
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
style={{
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
left: 0,
|
|
110
|
+
top: 8,
|
|
111
|
+
bottom: 8,
|
|
112
|
+
width: 4,
|
|
113
|
+
borderRadius: '0 4px 4px 0',
|
|
114
|
+
background: accent,
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
) : null;
|
|
118
|
+
|
|
119
|
+
const iconContainer = item.icon ? (
|
|
120
|
+
<span
|
|
121
|
+
className="pc-shell-nav-item-icon"
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
style={{
|
|
124
|
+
display: 'inline-flex',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
justifyContent: 'center',
|
|
127
|
+
width: 32,
|
|
128
|
+
height: 32,
|
|
129
|
+
borderRadius: 8,
|
|
130
|
+
background: containerBg,
|
|
131
|
+
color: containerColor,
|
|
132
|
+
flexShrink: 0,
|
|
133
|
+
transition: 'background-color 150ms ease, color 150ms ease, transform 150ms ease',
|
|
134
|
+
transform: containerTransform,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{item.icon}
|
|
138
|
+
</span>
|
|
139
|
+
) : null;
|
|
140
|
+
|
|
141
|
+
const label = !isCollapsed ? (
|
|
142
|
+
<span
|
|
143
|
+
style={{
|
|
144
|
+
flex: 1,
|
|
145
|
+
minWidth: 0,
|
|
146
|
+
overflow: 'hidden',
|
|
147
|
+
textOverflow: 'ellipsis',
|
|
148
|
+
whiteSpace: 'nowrap',
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{item.name}
|
|
152
|
+
</span>
|
|
153
|
+
) : null;
|
|
154
|
+
|
|
155
|
+
const badge = item.isNew && !isCollapsed && !isDisabled ? (
|
|
156
|
+
<span
|
|
157
|
+
style={{
|
|
158
|
+
display: 'inline-flex',
|
|
159
|
+
alignItems: 'center',
|
|
160
|
+
flexShrink: 0,
|
|
161
|
+
padding: '2px 8px',
|
|
162
|
+
borderRadius: 9999,
|
|
163
|
+
background: getVar('--pc-bg-hover'),
|
|
164
|
+
color: accent,
|
|
165
|
+
fontSize: 11,
|
|
166
|
+
fontWeight: 600,
|
|
167
|
+
lineHeight: 1.2,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
New
|
|
171
|
+
</span>
|
|
172
|
+
) : null;
|
|
173
|
+
|
|
174
|
+
const button = (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="pc-shell-nav-item"
|
|
178
|
+
role="link"
|
|
179
|
+
aria-current={isActive ? 'page' : undefined}
|
|
180
|
+
aria-disabled={isDisabled || undefined}
|
|
181
|
+
aria-label={isCollapsed ? item.name : undefined}
|
|
182
|
+
disabled={isDisabled}
|
|
183
|
+
onClick={handleActivate}
|
|
184
|
+
onKeyDown={handleKeyDown}
|
|
185
|
+
onMouseEnter={() => setHover(true)}
|
|
186
|
+
onMouseLeave={() => setHover(false)}
|
|
187
|
+
style={buttonStyle}
|
|
188
|
+
>
|
|
189
|
+
{accentBar}
|
|
190
|
+
{iconContainer}
|
|
191
|
+
{label}
|
|
192
|
+
{badge}
|
|
193
|
+
</button>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (isCollapsed) {
|
|
197
|
+
return (
|
|
198
|
+
<PCTooltip content={item.name} placement="right" color="invert">
|
|
199
|
+
<div style={{ width: 40, height: 40, margin: '0 auto' }}>{button}</div>
|
|
200
|
+
</PCTooltip>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return button;
|
|
205
|
+
}
|