@morphika/webframe 0.1.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 (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +210 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,582 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import Link from "next/link";
6
+ import type { NavItem, NavDesign, NavEntrancePreset } from "../../lib/sanity/types";
7
+ import { useNavColor } from "../../lib/contexts/NavColorContext";
8
+ import { useNavAnimation } from "../../lib/contexts/NavAnimationContext";
9
+ import { usePageExit } from "../../lib/contexts/PageExitContext";
10
+ import { getSiteConfig } from "../../lib/config";
11
+ import NavContentLightbox from "./NavContentLightbox";
12
+
13
+ // ============================================
14
+ // Color variant mapping
15
+ // ============================================
16
+
17
+ const colorMap: Record<string, string> = {
18
+ "yellow-lime": "text-brand-accent",
19
+ yellow: "text-brand-accent-alt",
20
+ "red-coral": "text-brand-secondary",
21
+ blue: "text-brand-primary",
22
+ green: "text-brand-accent-2",
23
+ white: "text-brand-text",
24
+ };
25
+
26
+ // ============================================
27
+ // NavLink — shared link component (eliminates duplication)
28
+ // ============================================
29
+
30
+ interface NavLinkProps {
31
+ item: NavItem;
32
+ className: string;
33
+ style?: React.CSSProperties;
34
+ onClick?: () => void;
35
+ currentPath: string;
36
+ activeClassName?: string;
37
+ /** Called when a content link is clicked — opens lightbox */
38
+ onContentClick?: (item: NavItem) => void;
39
+ }
40
+
41
+ function NavLink({
42
+ item,
43
+ className,
44
+ style,
45
+ onClick,
46
+ currentPath,
47
+ activeClassName = "opacity-60",
48
+ onContentClick,
49
+ }: NavLinkProps) {
50
+ // Content links render as buttons that open a lightbox
51
+ if (item.link_type === "content") {
52
+ const hasContent = item.content_asset || item.content_url;
53
+ return (
54
+ <button
55
+ className={className}
56
+ style={{ ...style, cursor: hasContent ? "pointer" : "default", opacity: hasContent ? 1 : 0.4, background: "none", border: "none", padding: 0 }}
57
+ onClick={() => {
58
+ if (hasContent && onContentClick) onContentClick(item);
59
+ if (onClick) onClick();
60
+ }}
61
+ >
62
+ {item.label}
63
+ </button>
64
+ );
65
+ }
66
+
67
+ const isExternal = item.link_type === "external";
68
+ const href = isExternal
69
+ ? item.external_url || undefined
70
+ : item.internal_page?.slug?.current
71
+ ? `/${item.internal_page.slug.current}`
72
+ : undefined;
73
+
74
+ // Check if this link is the active page
75
+ const isActive =
76
+ !isExternal && href && (currentPath === href || currentPath.startsWith(`${href}/`));
77
+
78
+ const resolvedClassName = `${className}${isActive ? ` ${activeClassName}` : ""}`;
79
+
80
+ // No valid href — render as inert text
81
+ if (!href) {
82
+ return (
83
+ <span className={className} style={{ ...style, cursor: "default", opacity: 0.4 }}>
84
+ {item.label}
85
+ </span>
86
+ );
87
+ }
88
+
89
+ if (isExternal) {
90
+ return (
91
+ <a
92
+ href={href}
93
+ target="_blank"
94
+ rel="noopener noreferrer"
95
+ className={resolvedClassName}
96
+ style={style}
97
+ onClick={onClick}
98
+ >
99
+ {item.label}
100
+ </a>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <Link href={href} className={resolvedClassName} style={style} onClick={onClick} aria-current={isActive ? "page" : undefined}>
106
+ {item.label}
107
+ </Link>
108
+ );
109
+ }
110
+
111
+ // ============================================
112
+ // Props
113
+ // ============================================
114
+
115
+ interface NavbarProps {
116
+ navItems?: NavItem[];
117
+ design?: NavDesign;
118
+ /** @deprecated Use design.color instead */
119
+ colorVariant?: string;
120
+ }
121
+
122
+ // ============================================
123
+ // Component
124
+ // ============================================
125
+
126
+ export default function Navbar({
127
+ navItems = [],
128
+ design,
129
+ colorVariant = "yellow-lime",
130
+ }: NavbarProps) {
131
+ // Resolve color: context > design > prop
132
+ const { navColor: contextColor } = useNavColor();
133
+ const activeColor = contextColor || design?.color || colorVariant;
134
+ const pathname = usePathname();
135
+ const { isExiting } = usePageExit();
136
+ const { override: navAnimOverride } = useNavAnimation();
137
+
138
+ // ── Entrance animation resolution (page override > global) ──
139
+ const entranceDisabled = navAnimOverride.disabled === true;
140
+ const entrancePreset: NavEntrancePreset | "" = entranceDisabled
141
+ ? ""
142
+ : (navAnimOverride.preset || design?.entrance_animation || "");
143
+ const entranceDuration = navAnimOverride.duration || design?.entrance_duration || 600;
144
+ const entranceDelay = navAnimOverride.delay ?? design?.entrance_delay ?? 0;
145
+ const entranceStagger = !entranceDisabled && (design?.entrance_stagger ?? false);
146
+ const entranceStaggerDelay = design?.entrance_stagger_delay ?? 80;
147
+
148
+ // Trigger entrance animation shortly after mount
149
+ const [navEntered, setNavEntered] = useState(!entrancePreset);
150
+ useEffect(() => {
151
+ if (!entrancePreset) {
152
+ setNavEntered(true);
153
+ return;
154
+ }
155
+ // Small delay to ensure the CSS initial hidden state paints first
156
+ const timer = requestAnimationFrame(() => setNavEntered(true));
157
+ return () => cancelAnimationFrame(timer);
158
+ }, [entrancePreset]);
159
+
160
+ // Resolve design values with defaults
161
+ const position = design?.position || "fixed";
162
+ const hideOnScroll = design?.hide_on_scroll !== false;
163
+ const fontSize = design?.font_size ?? 14;
164
+ const fontWeight = design?.font_weight || "400";
165
+ const fontFamily = design?.font_family || undefined;
166
+ const textTransformVal = design?.text_transform || "uppercase";
167
+ const textAlignVal = design?.text_align || "left";
168
+ const itemsJustify = textAlignVal === "center" ? "center" : textAlignVal === "right" ? "flex-end" : "flex-start";
169
+ const paddingH = design?.padding_h ?? 24;
170
+ const paddingV = design?.padding_v ?? 27;
171
+ const marginH = design?.margin_h ?? 0;
172
+ const marginV = design?.margin_v ?? 0;
173
+ const itemsGap = design?.items_gap ?? 32;
174
+ const bgColor = design?.background_color || "";
175
+ const bgOpacity = design?.background_opacity ?? 0;
176
+ const backdropBlur = !!design?.backdrop_blur;
177
+ const verticalAlign = design?.vertical_align || "top";
178
+ // Map vertical_align to CSS align-items value for the grid container
179
+ const gridAlignItems = verticalAlign === "bottom" ? "end" : verticalAlign === "middle" ? "center" : "start";
180
+
181
+ const [isVisible, setIsVisible] = useState(true);
182
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
183
+ const [lightboxItem, setLightboxItem] = useState<NavItem | null>(null);
184
+ const lastScrollY = useRef(0);
185
+ const ticking = useRef(false);
186
+ const hamburgerButtonRef = useRef<HTMLButtonElement>(null);
187
+ const mobileMenuRef = useRef<HTMLDivElement>(null);
188
+
189
+ // Close mobile menu on route change (back/forward navigation)
190
+ useEffect(() => {
191
+ setIsMenuOpen(false);
192
+ }, [pathname]);
193
+
194
+ // Close mobile menu immediately when page exit animations start
195
+ useEffect(() => {
196
+ if (isExiting) setIsMenuOpen(false);
197
+ }, [isExiting]);
198
+
199
+ // Focus management: return focus to hamburger when menu closes
200
+ const prevMenuOpen = useRef(false);
201
+ useEffect(() => {
202
+ if (!isMenuOpen && prevMenuOpen.current) {
203
+ hamburgerButtonRef.current?.focus();
204
+ }
205
+ prevMenuOpen.current = isMenuOpen;
206
+ }, [isMenuOpen]);
207
+
208
+ // Focus trap: handle Tab/Shift+Tab/Escape within mobile menu
209
+ useEffect(() => {
210
+ if (!isMenuOpen || !mobileMenuRef.current) return;
211
+
212
+ const handleKeyDown = (e: KeyboardEvent) => {
213
+ if (e.key === "Escape") {
214
+ e.preventDefault();
215
+ setIsMenuOpen(false);
216
+ return;
217
+ }
218
+
219
+ if (e.key !== "Tab") return;
220
+
221
+ const focusable = mobileMenuRef.current?.querySelectorAll<HTMLElement>(
222
+ 'a, button, [tabindex]:not([tabindex="-1"])'
223
+ );
224
+ if (!focusable || focusable.length === 0) return;
225
+
226
+ const first = focusable[0];
227
+ const last = focusable[focusable.length - 1];
228
+
229
+ if (e.shiftKey && document.activeElement === first) {
230
+ e.preventDefault();
231
+ last.focus();
232
+ } else if (!e.shiftKey && document.activeElement === last) {
233
+ e.preventDefault();
234
+ first.focus();
235
+ }
236
+ };
237
+
238
+ document.addEventListener("keydown", handleKeyDown);
239
+ return () => document.removeEventListener("keydown", handleKeyDown);
240
+ }, [isMenuOpen]);
241
+
242
+ // Hide on scroll down, show on scroll up
243
+ const handleScroll = useCallback(() => {
244
+ if (!hideOnScroll) return;
245
+ if (ticking.current) return;
246
+
247
+ ticking.current = true;
248
+ requestAnimationFrame(() => {
249
+ const currentScrollY = window.scrollY;
250
+
251
+ if (currentScrollY < 10) {
252
+ setIsVisible(true);
253
+ } else if (currentScrollY > lastScrollY.current && currentScrollY > 80) {
254
+ setIsVisible(false);
255
+ } else if (currentScrollY < lastScrollY.current) {
256
+ setIsVisible(true);
257
+ }
258
+
259
+ lastScrollY.current = currentScrollY;
260
+ ticking.current = false;
261
+ });
262
+ }, [hideOnScroll]);
263
+
264
+ useEffect(() => {
265
+ if (position === "static") return;
266
+ window.addEventListener("scroll", handleScroll, { passive: true });
267
+ return () => window.removeEventListener("scroll", handleScroll);
268
+ }, [handleScroll, position]);
269
+
270
+ // Lock body scroll when mobile menu is open
271
+ useEffect(() => {
272
+ if (isMenuOpen) {
273
+ document.body.style.overflow = "hidden";
274
+ } else {
275
+ document.body.style.overflow = "";
276
+ }
277
+ return () => {
278
+ document.body.style.overflow = "";
279
+ };
280
+ }, [isMenuOpen]);
281
+
282
+ // If activeColor is a hex string, use inline style; otherwise use Tailwind class
283
+ const isHexColor = activeColor.startsWith("#");
284
+ const textColorClass = isHexColor ? "" : (colorMap[activeColor] || colorMap["yellow-lime"]);
285
+ const textColorStyle: React.CSSProperties | undefined = isHexColor ? { color: activeColor } : undefined;
286
+
287
+ // Build background style
288
+ const navBgStyle: React.CSSProperties = {};
289
+ if (bgColor && bgOpacity > 0) {
290
+ const hex = bgColor.replace("#", "");
291
+ if (hex.length >= 6) {
292
+ const r = parseInt(hex.slice(0, 2), 16);
293
+ const g = parseInt(hex.slice(2, 4), 16);
294
+ const b = parseInt(hex.slice(4, 6), 16);
295
+ navBgStyle.backgroundColor = `rgba(${r}, ${g}, ${b}, ${bgOpacity / 100})`;
296
+ }
297
+ }
298
+ if (backdropBlur) {
299
+ navBgStyle.backdropFilter = "blur(12px)";
300
+ navBgStyle.WebkitBackdropFilter = "blur(12px)";
301
+ }
302
+
303
+ // Position class
304
+ const positionClass =
305
+ position === "fixed"
306
+ ? "fixed"
307
+ : position === "sticky"
308
+ ? "sticky"
309
+ : "relative";
310
+
311
+ // Visibility: only applies to fixed/sticky when hideOnScroll
312
+ const shouldHide = position !== "static" && hideOnScroll && !isVisible;
313
+
314
+ // Grid layout: detect new format (items with type field) vs legacy (logo in design)
315
+ const visibleItems = navItems.filter((item) => item.visible !== false);
316
+ const hasNewFormat = visibleItems.some((i) => i.type === "logo" || i.type === "menu-item");
317
+
318
+ // New format: logo is a nav item; legacy: logo from design
319
+ const logoItem = hasNewFormat ? visibleItems.find((i) => i.type === "logo") : null;
320
+ // Filter out ALL logo-type items from menu items (prevents duplicate rendering)
321
+ const menuItems = hasNewFormat
322
+ ? visibleItems.filter((i) => i.type !== "logo")
323
+ : visibleItems;
324
+ const logoCols = logoItem ? logoItem.column_span : (design?.logo_columns ?? 3);
325
+ const logoLabel = logoItem?.label || design?.logo_text || getSiteConfig().defaults.logoText;
326
+
327
+ const placedItems = menuItems.filter((i) => typeof i.grid_column === "number" && i.grid_column > 0);
328
+ const unplacedItems = menuItems.filter((i) => typeof i.grid_column !== "number" || i.grid_column <= 0);
329
+
330
+ // Shared link styling (textTransform + fontFamily applied via inline style)
331
+ // When using hex color, we must set `color: inherit` on links because browser
332
+ // user-agent stylesheet sets `a { color: ... }` which overrides CSS inheritance.
333
+ const linkClassName = `tracking-normal ${textColorClass} transition-colors duration-200 hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`;
334
+ const linkStyle: React.CSSProperties = {
335
+ fontSize: `${fontSize}px`,
336
+ fontWeight: fontWeight as React.CSSProperties["fontWeight"],
337
+ textTransform: textTransformVal as React.CSSProperties["textTransform"],
338
+ fontFamily: fontFamily || "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)",
339
+ ...(isHexColor ? { color: "inherit" } : {}),
340
+ };
341
+
342
+ return (
343
+ <>
344
+ {/* Navbar */}
345
+ <nav
346
+ role="navigation"
347
+ aria-label="Main navigation"
348
+ className={`${positionClass} top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out ${
349
+ shouldHide ? "-translate-y-full" : "translate-y-0"
350
+ }`}
351
+ // Nav entrance animation (whole nav when no stagger)
352
+ {...(entrancePreset && !entranceStagger ? { "data-nav-entrance": entrancePreset } : {})}
353
+ {...(navEntered && entrancePreset && !entranceStagger ? { "data-nav-entered": "" } : {})}
354
+ style={{
355
+ ...navBgStyle,
356
+ ...textColorStyle,
357
+ ...(marginH > 0 || marginV > 0
358
+ ? {
359
+ left: `${marginH}px`,
360
+ right: `${marginH}px`,
361
+ top: `${marginV}px`,
362
+ borderRadius: "8px",
363
+ }
364
+ : {}),
365
+ // CSS custom properties for animation duration
366
+ ...(entrancePreset && !entranceStagger ? {
367
+ "--nav-entrance-duration": `${entranceDuration}ms`,
368
+ "--nav-entrance-delay": `${entranceDelay}ms`,
369
+ } as React.CSSProperties : {}),
370
+ }}
371
+ >
372
+ {/* Desktop: 12-column grid constrained to --grid-width */}
373
+ <div
374
+ className="hidden lg:grid"
375
+ style={{
376
+ gridTemplateColumns: "repeat(12, 1fr)",
377
+ maxWidth: "var(--grid-width, 1445px)",
378
+ marginLeft: "auto",
379
+ marginRight: "auto",
380
+ paddingLeft: `${paddingH}px`,
381
+ paddingRight: `${paddingH}px`,
382
+ paddingTop: `${paddingV}px`,
383
+ paddingBottom: `${paddingV}px`,
384
+ alignItems: gridAlignItems,
385
+ columnGap: `${itemsGap}px`,
386
+ }}
387
+ >
388
+ {/* Logo */}
389
+ <div
390
+ {...(entrancePreset && entranceStagger ? { "data-nav-item-entrance": entrancePreset } : {})}
391
+ {...(navEntered && entrancePreset && entranceStagger ? { "data-nav-entered": "" } : {})}
392
+ style={{
393
+ gridColumn: logoItem ? `${logoItem.grid_column} / span ${logoItem.column_span}` : `1 / span ${logoCols}`,
394
+ gridRow: 1,
395
+ display: "flex",
396
+ justifyContent: "flex-start",
397
+ alignItems: "center",
398
+ minWidth: 0,
399
+ overflow: "hidden",
400
+ whiteSpace: "nowrap",
401
+ ...(logoItem?.style_overrides?.vertical_align ? {
402
+ alignSelf: logoItem.style_overrides.vertical_align === "bottom" ? "end" : logoItem.style_overrides.vertical_align === "middle" ? "center" : "start",
403
+ } : {}),
404
+ ...(entrancePreset && entranceStagger ? {
405
+ "--nav-item-duration": `${Math.round(entranceDuration * 0.7)}ms`,
406
+ "--nav-item-delay": `${entranceDelay}ms`,
407
+ } as React.CSSProperties : {}),
408
+ }}
409
+ >
410
+ <Link
411
+ href="/"
412
+ className={linkClassName}
413
+ style={{
414
+ ...linkStyle,
415
+ ...(logoItem?.style_overrides?.font_size ? { fontSize: `${logoItem.style_overrides.font_size}px` } : {}),
416
+ ...(logoItem?.style_overrides?.font_weight ? { fontWeight: logoItem.style_overrides.font_weight } : {}),
417
+ ...(logoItem?.style_overrides?.font_family ? { fontFamily: logoItem.style_overrides.font_family } : {}),
418
+ ...(logoItem?.style_overrides?.color ? { color: logoItem.style_overrides.color } : {}),
419
+ ...(logoItem?.style_overrides?.text_transform ? { textTransform: logoItem.style_overrides.text_transform as React.CSSProperties["textTransform"] } : {}),
420
+ }}
421
+ >
422
+ {logoLabel}
423
+ </Link>
424
+ </div>
425
+
426
+ {/* Items with grid_column — each in its own column(s) */}
427
+ {placedItems.map((item, idx) => {
428
+ const itemVAlign = item.style_overrides?.vertical_align;
429
+ // Stagger: logo is index 0, first menu item is index 1, etc.
430
+ const staggerIdx = idx + 1;
431
+ return (
432
+ <div
433
+ key={item._key}
434
+ {...(entrancePreset && entranceStagger ? { "data-nav-item-entrance": entrancePreset } : {})}
435
+ {...(navEntered && entrancePreset && entranceStagger ? { "data-nav-entered": "" } : {})}
436
+ style={{
437
+ gridColumn: `${item.grid_column} / span ${item.column_span || 1}`,
438
+ gridRow: 1,
439
+ display: "flex",
440
+ justifyContent: itemsJustify,
441
+ alignItems: "center",
442
+ minWidth: 0,
443
+ overflow: "hidden",
444
+ whiteSpace: "nowrap",
445
+ ...(itemVAlign ? {
446
+ alignSelf: itemVAlign === "bottom" ? "end" : itemVAlign === "middle" ? "center" : "start",
447
+ } : {}),
448
+ ...(entrancePreset && entranceStagger ? {
449
+ "--nav-item-duration": `${Math.round(entranceDuration * 0.7)}ms`,
450
+ "--nav-item-delay": `${entranceDelay + staggerIdx * entranceStaggerDelay}ms`,
451
+ } as React.CSSProperties : {}),
452
+ }}
453
+ >
454
+ <NavLink
455
+ item={item}
456
+ className={linkClassName}
457
+ style={{
458
+ ...linkStyle,
459
+ ...(item.style_overrides?.font_size ? { fontSize: `${item.style_overrides.font_size}px` } : {}),
460
+ ...(item.style_overrides?.font_weight ? { fontWeight: item.style_overrides.font_weight } : {}),
461
+ ...(item.style_overrides?.font_family ? { fontFamily: item.style_overrides.font_family } : {}),
462
+ ...(item.style_overrides?.color ? { color: item.style_overrides.color } : {}),
463
+ ...(item.style_overrides?.text_transform ? { textTransform: item.style_overrides.text_transform as React.CSSProperties["textTransform"] } : {}),
464
+ }}
465
+ currentPath={pathname}
466
+ onContentClick={setLightboxItem}
467
+ />
468
+ </div>
469
+ );
470
+ })}
471
+
472
+ {/* Fallback: unplaced items in remaining columns */}
473
+ {unplacedItems.length > 0 && (
474
+ <div
475
+ style={{
476
+ gridColumn: `${logoCols + 1} / -1`,
477
+ display: "flex",
478
+ justifyContent: itemsJustify,
479
+ alignItems: "center",
480
+ gap: `${design?.items_gap ?? 32}px`,
481
+ }}
482
+ >
483
+ {unplacedItems.map((item) => (
484
+ <NavLink
485
+ key={item._key}
486
+ item={item}
487
+ className={linkClassName}
488
+ style={linkStyle}
489
+ currentPath={pathname}
490
+ />
491
+ ))}
492
+ </div>
493
+ )}
494
+ </div>
495
+
496
+ {/* Mobile: simple flex layout with hamburger */}
497
+ <div
498
+ className="flex lg:hidden items-center justify-between"
499
+ style={{
500
+ maxWidth: "var(--grid-width, 1445px)",
501
+ marginLeft: "auto",
502
+ marginRight: "auto",
503
+ paddingLeft: `${paddingH}px`,
504
+ paddingRight: `${paddingH}px`,
505
+ paddingTop: `${paddingV}px`,
506
+ paddingBottom: `${paddingV}px`,
507
+ }}
508
+ >
509
+ <Link
510
+ href="/"
511
+ className={linkClassName}
512
+ style={linkStyle}
513
+ >
514
+ {logoLabel}
515
+ </Link>
516
+
517
+ <button
518
+ ref={hamburgerButtonRef}
519
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
520
+ className={`flex flex-col justify-center items-center gap-[5px] w-8 h-8 ${textColorClass} focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`}
521
+ aria-label={isMenuOpen ? "Close menu" : "Open menu"}
522
+ aria-expanded={isMenuOpen}
523
+ aria-controls="mobile-nav-menu"
524
+ >
525
+ <span
526
+ className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
527
+ isMenuOpen ? "rotate-45 translate-y-[6.5px]" : ""
528
+ }`}
529
+ />
530
+ <span
531
+ className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
532
+ isMenuOpen ? "opacity-0" : ""
533
+ }`}
534
+ />
535
+ <span
536
+ className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
537
+ isMenuOpen ? "-rotate-45 -translate-y-[6.5px]" : ""
538
+ }`}
539
+ />
540
+ </button>
541
+ </div>
542
+ </nav>
543
+
544
+ {/* Mobile overlay menu */}
545
+ <div
546
+ ref={mobileMenuRef}
547
+ id="mobile-nav-menu"
548
+ role="dialog"
549
+ aria-modal={isMenuOpen}
550
+ aria-label="Mobile navigation menu"
551
+ className={`fixed inset-0 z-40 bg-brand-dark transition-opacity duration-300 lg:hidden ${
552
+ isMenuOpen
553
+ ? "opacity-100 pointer-events-auto"
554
+ : "opacity-0 pointer-events-none"
555
+ }`}
556
+ >
557
+ <div className="flex flex-col items-center justify-center h-full gap-8">
558
+ {[...menuItems].sort((a, b) => (a.grid_column || 0) - (b.grid_column || 0)).map((item) => (
559
+ <NavLink
560
+ key={item._key}
561
+ item={item}
562
+ className={`font-mono text-2xl tracking-wide ${textColorClass} transition-colors duration-200 hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`}
563
+ style={{ textTransform: textTransformVal as React.CSSProperties["textTransform"], ...(fontFamily ? { fontFamily } : {}), ...(isHexColor ? { color: "inherit" } : {}) }}
564
+ currentPath={pathname}
565
+ onContentClick={(clickedItem) => { setLightboxItem(clickedItem); setIsMenuOpen(false); }}
566
+ />
567
+ ))}
568
+ </div>
569
+ </div>
570
+
571
+ {/* Content lightbox */}
572
+ {lightboxItem && lightboxItem.link_type === "content" && (
573
+ <NavContentLightbox
574
+ contentType={lightboxItem.content_type || "image"}
575
+ contentAsset={lightboxItem.content_asset}
576
+ contentUrl={lightboxItem.content_url}
577
+ onClose={() => setLightboxItem(null)}
578
+ />
579
+ )}
580
+ </>
581
+ );
582
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PortfolioTracker — Outreach & campaign visit tracking.
5
+ *
6
+ * Tracks two types of visits:
7
+ * 1. Lead referrals: example.com/portfolio?r=AM92
8
+ * 2. Custom channels: example.com/portfolio?c=discord
9
+ *
10
+ * Sends a POST to the Knock API on first load and on SPA route changes.
11
+ * Uses sessionStorage to persist ref/channel across internal navigation.
12
+ *
13
+ * Configured via site.config.ts: tracking.knockApiUrl + tracking.sessionPrefix.
14
+ * If knockApiUrl is empty, the component renders nothing.
15
+ */
16
+
17
+ import { useEffect, useRef } from "react";
18
+ import { usePathname } from "next/navigation";
19
+ import { getSiteConfig } from "../../lib/config";
20
+
21
+ const cfg = getSiteConfig();
22
+ const KNOCK_API_URL = cfg.tracking.knockApiUrl;
23
+ const REF_KEY = `${cfg.tracking.sessionPrefix}_ref`;
24
+ const CHANNEL_KEY = `${cfg.tracking.sessionPrefix}_channel`;
25
+
26
+ function sendVisit(
27
+ page: string,
28
+ ref: string | null,
29
+ channel: string | null,
30
+ ): void {
31
+ if (!ref && !channel) return;
32
+
33
+ const body: Record<string, string> = {
34
+ page,
35
+ timestamp: new Date().toISOString(),
36
+ };
37
+ if (ref) body.ref = ref;
38
+ if (channel) body.channel = channel;
39
+
40
+ fetch(KNOCK_API_URL, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify(body),
44
+ }).catch(() => {
45
+ /* silent — tracking should never break the site */
46
+ });
47
+ }
48
+
49
+ export default function PortfolioTracker() {
50
+ const pathname = usePathname();
51
+ const prevPathRef = useRef<string | null>(null);
52
+ const enabled = !!KNOCK_API_URL;
53
+
54
+ // Initial visit + capture URL params into sessionStorage
55
+ useEffect(() => {
56
+ if (!enabled) return;
57
+
58
+ const params = new URLSearchParams(window.location.search);
59
+ const ref = params.get("r") || params.get("ref");
60
+ const channel = params.get("c");
61
+
62
+ if (ref) sessionStorage.setItem(REF_KEY, ref);
63
+ if (channel) sessionStorage.setItem(CHANNEL_KEY, channel);
64
+
65
+ const storedRef = ref || sessionStorage.getItem(REF_KEY);
66
+ const storedChannel = channel || sessionStorage.getItem(CHANNEL_KEY);
67
+
68
+ if (storedRef || storedChannel) {
69
+ sendVisit(window.location.pathname, storedRef, storedChannel);
70
+ }
71
+
72
+ prevPathRef.current = window.location.pathname;
73
+ }, [enabled]); // eslint-disable-line react-hooks/exhaustive-deps
74
+
75
+ // SPA route change tracking (Next.js App Router)
76
+ useEffect(() => {
77
+ if (!enabled) return;
78
+ if (prevPathRef.current === null || pathname === prevPathRef.current) return;
79
+ prevPathRef.current = pathname;
80
+
81
+ const ref = sessionStorage.getItem(REF_KEY);
82
+ const channel = sessionStorage.getItem(CHANNEL_KEY);
83
+ sendVisit(pathname, ref, channel);
84
+ }, [pathname, enabled]);
85
+
86
+ return null;
87
+ }