diff --git a/.env b/.env
index 24a522ca..4d366e72 100644
--- a/.env
+++ b/.env
@@ -4,7 +4,8 @@ NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
-NEXT_PUBLIC_FEEDBACK_ENABLED=true
+NEXT_PUBLIC_FEEDBACK_ENABLED=false
+NEXT_PUBLIC_RECORD_MODE_ENABLED=false
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
diff --git a/.env.example b/.env.example
index 21aa6f64..c6e998e8 100644
--- a/.env.example
+++ b/.env.example
@@ -15,6 +15,7 @@ DIRECTUS_PORT=8055
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
+NEXT_PUBLIC_RECORD_MODE_ENABLED=true
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 0cfef71b..0deeafc5 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -51,7 +51,6 @@ export default async function LocaleLayout({
}) {
const { locale } = await params;
- // Ensure locale is a valid string, fallback to 'en'
const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
@@ -66,12 +65,9 @@ export default async function LocaleLayout({
messages = {};
}
- // Track pageview on the server with high-fidelity header context
const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices();
- // We wrap this in a try-catch to allow static rendering during build
- // headers() and cookies() force dynamic rendering in Next.js
try {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
@@ -85,10 +81,8 @@ export default async function LocaleLayout({
});
}
- // Track initial server-side pageview
serverServices.analytics.trackPageview();
} catch {
- // Falls back to noop or client-side only during static generation
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn(
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
@@ -96,38 +90,29 @@ export default async function LocaleLayout({
}
}
+ // Read directly from process.env — bypasses all abstraction to guarantee correctness
+ const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
+ const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
+
return (
-
+
-
+
@@ -140,7 +125,7 @@ export default async function LocaleLayout({
-
+
diff --git a/components/record-mode/RecordModeContext.tsx b/components/record-mode/RecordModeContext.tsx
index e3741cea..5a2dd0d4 100644
--- a/components/record-mode/RecordModeContext.tsx
+++ b/components/record-mode/RecordModeContext.tsx
@@ -26,6 +26,7 @@ interface RecordModeContextType {
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
+ isEnabled: boolean;
}
const RecordModeContext = createContext(null);
@@ -56,12 +57,19 @@ export function useRecordMode(): RecordModeContextType {
setHoveredEventId: () => {},
setEvents: () => {},
isClicking: false,
+ isEnabled: false,
};
}
return context;
}
-export function RecordModeProvider({ children }: { children: React.ReactNode }) {
+export function RecordModeProvider({
+ children,
+ isEnabled = false,
+}: {
+ children: React.ReactNode;
+ isEnabled?: boolean;
+}) {
const [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
@@ -74,45 +82,54 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => {
+ console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
+ }, [isEnabled]);
+
+ useEffect(() => {
+ if (!isEnabled) return;
const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top);
setIsEmbedded(embedded);
- }, []);
+ }, [isEnabled]);
const setIsActive = (active: boolean) => {
+ if (!isEnabled) return;
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
- if (active) setIsActiveState(false);
+ if (active && isEnabled) setIsActiveState(false);
};
const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
+ if (!isEnabled) return;
const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true;
- }, []);
+ }, [isEnabled]);
useEffect(() => {
- if (!isLoadedRef.current) return;
+ if (!isEnabled || !isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events));
- }, [events]);
+ }, [events, isEnabled]);
useEffect(() => {
+ if (!isEnabled) return;
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
- }, [isActive]);
+ }, [isActive, isEnabled]);
useEffect(() => {
+ if (!isEnabled) return;
if (isEmbedded) {
const handlePlaybackMessage = (e: MessageEvent) => {
if (e.data.type === 'PLAY_EVENT') {
@@ -177,10 +194,10 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
window.addEventListener('message', handlePlaybackMessage);
return () => window.removeEventListener('message', handlePlaybackMessage);
}
- }, [isEmbedded]);
+ }, [isEmbedded, isEnabled]);
useEffect(() => {
- if (isEmbedded || !isActive) return;
+ if (!isEnabled || isEmbedded || !isActive) return;
const event = events.find((e) => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) {
@@ -189,9 +206,10 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
'*',
);
}
- }, [hoveredEventId, events, isActive, isEmbedded]);
+ }, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
const addEvent = (event: Omit) => {
+ if (!isEnabled) return;
const newEvent: RecordEvent = {
realClick: false,
...event,
@@ -202,12 +220,14 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
};
const updateEvent = (id: string, updatedFields: Partial) => {
+ if (!isEnabled) return;
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
);
};
const reorderEvents = (startIndex: number, endIndex: number) => {
+ if (!isEnabled) return;
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
@@ -215,10 +235,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
};
const removeEvent = (id: string) => {
+ if (!isEnabled) return;
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
+ if (!isEnabled) return;
if (confirm('Clear all recorded events?')) setEvents([]);
};
@@ -233,11 +255,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
: null;
const saveSession = (name: string) => {
+ if (!isEnabled) return;
console.log('Saving session:', name, events);
};
const playEvents = async () => {
- if (events.length === 0 || isPlayingRef.current) return;
+ if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true);
isPlayingRef.current = true;
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
@@ -263,7 +286,6 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
} else {
- // Self-execution logic for guest
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -361,6 +383,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
hoveredEventId,
setHoveredEventId,
isClicking,
+ isEnabled,
}}
>
{children}
diff --git a/components/record-mode/ToolCoordinator.tsx b/components/record-mode/ToolCoordinator.tsx
index b6163d0f..5f710466 100644
--- a/components/record-mode/ToolCoordinator.tsx
+++ b/components/record-mode/ToolCoordinator.tsx
@@ -2,62 +2,65 @@
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
-import { useSearchParams } from 'next/navigation';
import { FeedbackOverlay } from '@mintel/next-feedback';
import { RecordModeOverlay } from './RecordModeOverlay';
import { PickingHelper } from './PickingHelper';
-import { config } from '@/lib/config';
-export function ToolCoordinator({ isEmbedded: isEmbeddedProp }: { isEmbedded?: boolean }) {
- const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive } = useRecordMode();
- const [isEmbedded, setIsEmbedded] = useState(false);
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- const embedded =
- isEmbeddedProp ||
- window.location.search.includes('embedded=true') ||
- window.name === 'record-mode-iframe' ||
- (window.self !== window.top);
- setIsEmbedded(embedded);
- }, [isEmbeddedProp]);
-
- if (!mounted) return null;
-
- // ABSOLUTE Priority 1: Inside Iframe - ONLY Rendering PickingHelper
- if (isEmbedded) {
- return ;
- }
-
- // ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED
- if (isActive) {
- return ;
- }
-
- // Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED
- if (isFeedbackActive) {
- return (
- setIsFeedbackActive(active)}
- />
- );
- }
-
- // Baseline: Both toggle buttons (inactive state)
- // Only render if neither is active to prevent any overlapping residues
- // IMPORTANT: FeedbackOverlay must be rendered with isActive={false} to provide the toggle button,
- // but only if Record Mode is not active.
- return (
-
- {config.feedbackEnabled && (
- setIsFeedbackActive(active)}
- />
- )}
-
-
- );
+interface ToolCoordinatorProps {
+ isEmbedded?: boolean;
+ feedbackEnabled?: boolean;
+}
+
+export function ToolCoordinator({
+ isEmbedded: isEmbeddedProp,
+ feedbackEnabled = false,
+}: ToolCoordinatorProps) {
+ const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
+ useRecordMode();
+ const [isEmbedded, setIsEmbedded] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ const embedded =
+ isEmbeddedProp ||
+ window.location.search.includes('embedded=true') ||
+ window.name === 'record-mode-iframe' ||
+ window.self !== window.top;
+ setIsEmbedded(embedded);
+ }, [isEmbeddedProp]);
+
+ if (!mounted) return null;
+
+ // Nothing enabled → render nothing
+ if (!feedbackEnabled && !isEnabled) return null;
+
+ // Iframe → only PickingHelper
+ if (isEmbedded) return ;
+
+ // Record Mode active and enabled
+ if (isActive && isEnabled) return ;
+
+ // Feedback active and enabled
+ if (isFeedbackActive && feedbackEnabled) {
+ return (
+ setIsFeedbackActive(active)}
+ />
+ );
+ }
+
+ // Baseline: toggle buttons
+ return (
+
+ {feedbackEnabled && (
+ setIsFeedbackActive(active)}
+ />
+ )}
+ {isEnabled && }
+
+ );
}
diff --git a/lib/config.ts b/lib/config.ts
index 59b440d8..7bfa28fe 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -15,6 +15,11 @@ function createConfig() {
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
+ console.log('[Config] Initializing Toggles:', {
+ feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
+ recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
+ });
+
return {
env: env.NODE_ENV,
target,
@@ -23,6 +28,7 @@ function createConfig() {
isTesting: target === 'testing',
isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
+ recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL,
baseUrl: env.NEXT_PUBLIC_BASE_URL,
@@ -144,6 +150,9 @@ export const config = {
get feedbackEnabled() {
return getConfig().feedbackEnabled;
},
+ get recordModeEnabled() {
+ return getConfig().recordModeEnabled;
+ },
get infraCMS() {
return getConfig().infraCMS;
},
diff --git a/lib/env.ts b/lib/env.ts
index 72df1efe..47567130 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -1,6 +1,18 @@
import { z } from 'zod';
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils';
+/**
+ * Robust boolean preprocessor for environment variables.
+ * Handles strings 'true'/'false' and actual booleans.
+ */
+const booleanSchema = z.preprocess((val) => {
+ if (typeof val === 'string') {
+ if (val.toLowerCase() === 'true') return true;
+ if (val.toLowerCase() === 'false') return false;
+ }
+ return val;
+}, z.boolean());
+
/**
* Environment variable schema.
* Extends the default Mintel environment schema.
@@ -11,14 +23,9 @@ const envExtension = {
// Gatekeeper specifics not in base
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
- GATEKEEPER_BYPASS_ENABLED: z.preprocess(
- (val) => val === 'true' || val === true,
- z.boolean().default(false),
- ),
- NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
- (val) => val === 'true' || val === true,
- z.boolean().default(false),
- ),
+ GATEKEEPER_BYPASS_ENABLED: booleanSchema.default(false),
+ NEXT_PUBLIC_FEEDBACK_ENABLED: booleanSchema.default(false),
+ NEXT_PUBLIC_RECORD_MODE_ENABLED: booleanSchema.default(false),
INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(),