feat: conditionally enable recording studio and feedback tool via env vars

This commit is contained in:
2026-02-15 20:59:12 +01:00
parent 9274807427
commit 7a63a418f1
7 changed files with 137 additions and 108 deletions

3
.env
View File

@@ -4,7 +4,8 @@ NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1 SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=true NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
# SMTP Configuration # SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org MAIL_HOST=smtp.eu.mailgun.org

View File

@@ -15,6 +15,7 @@ DIRECTUS_PORT=8055
# NEXT_PUBLIC_TARGET makes this information available to the frontend # NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami) # Analytics (Umami)

View File

@@ -51,7 +51,6 @@ export default async function LocaleLayout({
}) { }) {
const { locale } = await params; const { locale } = await params;
// Ensure locale is a valid string, fallback to 'en'
const supportedLocales = ['en', 'de']; const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim(); const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en'; const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
@@ -66,12 +65,9 @@ export default async function LocaleLayout({
messages = {}; messages = {};
} }
// Track pageview on the server with high-fidelity header context
const { getServerAppServices } = await import('@/lib/services/create-services.server'); const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices(); 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 { try {
const { headers } = await import('next/headers'); const { headers } = await import('next/headers');
const requestHeaders = await headers(); const requestHeaders = await headers();
@@ -85,10 +81,8 @@ export default async function LocaleLayout({
}); });
} }
// Track initial server-side pageview
serverServices.analytics.trackPageview(); serverServices.analytics.trackPageview();
} catch { } catch {
// Falls back to noop or client-side only during static generation
if (process.env.NODE_ENV !== 'production' || !process.env.CI) { if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn( console.warn(
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context', '[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 ( return (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}> <html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head> <head>
<style dangerouslySetInnerHTML={{ <style
__html: ` dangerouslySetInnerHTML={{
/* Effectively Invisible Scrollbar */ __html: `
::-webkit-scrollbar { ::-webkit-scrollbar { width: 2px; height: 2px; }
width: 2px; ::-webkit-scrollbar-track { background: transparent; }
height: 2px; ::-webkit-scrollbar-thumb { background: transparent; border-radius: 10px; }
} ::-webkit-scrollbar-thumb:hover { background: rgba(130, 237, 32, 0.4); }
::-webkit-scrollbar-track { * { scrollbar-width: none; }
background: transparent; *:hover { scrollbar-width: thin; scrollbar-color: rgba(130, 237, 32, 0.2) transparent; }
} `,
::-webkit-scrollbar-thumb { }}
background: transparent; />
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(130, 237, 32, 0.4);
}
* {
scrollbar-width: none;
}
*:hover {
scrollbar-width: thin;
scrollbar-color: rgba(130, 237, 32, 0.2) transparent;
}
`}} />
</head> </head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden"> <body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={messages} locale={safeLocale}> <NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider> <RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals> <RecordModeVisuals>
<JsonLd /> <JsonLd />
<Header /> <Header />
@@ -140,7 +125,7 @@ export default async function LocaleLayout({
<Suspense fallback={null}> <Suspense fallback={null}>
<AnalyticsProvider /> <AnalyticsProvider />
</Suspense> </Suspense>
<ToolCoordinator /> <ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider> </RecordModeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>

View File

@@ -26,6 +26,7 @@ interface RecordModeContextType {
hoveredEventId: string | null; hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void; setHoveredEventId: (id: string | null) => void;
isClicking: boolean; isClicking: boolean;
isEnabled: boolean;
} }
const RecordModeContext = createContext<RecordModeContextType | null>(null); const RecordModeContext = createContext<RecordModeContextType | null>(null);
@@ -56,12 +57,19 @@ export function useRecordMode(): RecordModeContextType {
setHoveredEventId: () => {}, setHoveredEventId: () => {},
setEvents: () => {}, setEvents: () => {},
isClicking: false, isClicking: false,
isEnabled: false,
}; };
} }
return context; 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 [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState<RecordEvent[]>([]); const [events, setEvents] = useState<RecordEvent[]>([]);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -74,45 +82,54 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const [isEmbedded, setIsEmbedded] = useState(false); const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => { useEffect(() => {
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
}, [isEnabled]);
useEffect(() => {
if (!isEnabled) return;
const embedded = const embedded =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') || (window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' || window.name === 'record-mode-iframe' ||
window.self !== window.top); window.self !== window.top);
setIsEmbedded(embedded); setIsEmbedded(embedded);
}, []); }, [isEnabled]);
const setIsActive = (active: boolean) => { const setIsActive = (active: boolean) => {
if (!isEnabled) return;
setIsActiveState(active); setIsActiveState(active);
if (active) setIsFeedbackActiveState(false); if (active) setIsFeedbackActiveState(false);
}; };
const setIsFeedbackActive = (active: boolean) => { const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active); setIsFeedbackActiveState(active);
if (active) setIsActiveState(false); if (active && isEnabled) setIsActiveState(false);
}; };
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false); const isLoadedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!isEnabled) return;
const savedEvents = localStorage.getItem('klz-record-events'); const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active'); const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents)); if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive)); if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true; isLoadedRef.current = true;
}, []); }, [isEnabled]);
useEffect(() => { useEffect(() => {
if (!isLoadedRef.current) return; if (!isEnabled || !isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events)); localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events]); }, [events, isEnabled]);
useEffect(() => { useEffect(() => {
if (!isEnabled) return;
localStorage.setItem('klz-record-active', JSON.stringify(isActive)); localStorage.setItem('klz-record-active', JSON.stringify(isActive));
}, [isActive]); }, [isActive, isEnabled]);
useEffect(() => { useEffect(() => {
if (!isEnabled) return;
if (isEmbedded) { if (isEmbedded) {
const handlePlaybackMessage = (e: MessageEvent) => { const handlePlaybackMessage = (e: MessageEvent) => {
if (e.data.type === 'PLAY_EVENT') { if (e.data.type === 'PLAY_EVENT') {
@@ -177,10 +194,10 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
window.addEventListener('message', handlePlaybackMessage); window.addEventListener('message', handlePlaybackMessage);
return () => window.removeEventListener('message', handlePlaybackMessage); return () => window.removeEventListener('message', handlePlaybackMessage);
} }
}, [isEmbedded]); }, [isEmbedded, isEnabled]);
useEffect(() => { useEffect(() => {
if (isEmbedded || !isActive) return; if (!isEnabled || isEmbedded || !isActive) return;
const event = events.find((e) => e.id === hoveredEventId); const event = events.find((e) => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement; const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) { 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<RecordEvent, 'id' | 'timestamp'>) => { const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
if (!isEnabled) return;
const newEvent: RecordEvent = { const newEvent: RecordEvent = {
realClick: false, realClick: false,
...event, ...event,
@@ -202,12 +220,14 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
}; };
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => { const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
if (!isEnabled) return;
setEvents((prev) => setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)), prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
); );
}; };
const reorderEvents = (startIndex: number, endIndex: number) => { const reorderEvents = (startIndex: number, endIndex: number) => {
if (!isEnabled) return;
const result = Array.from(events); const result = Array.from(events);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed); result.splice(endIndex, 0, removed);
@@ -215,10 +235,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
}; };
const removeEvent = (id: string) => { const removeEvent = (id: string) => {
if (!isEnabled) return;
setEvents((prev) => prev.filter((event) => event.id !== id)); setEvents((prev) => prev.filter((event) => event.id !== id));
}; };
const clearEvents = () => { const clearEvents = () => {
if (!isEnabled) return;
if (confirm('Clear all recorded events?')) setEvents([]); if (confirm('Clear all recorded events?')) setEvents([]);
}; };
@@ -233,11 +255,12 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
: null; : null;
const saveSession = (name: string) => { const saveSession = (name: string) => {
if (!isEnabled) return;
console.log('Saving session:', name, events); console.log('Saving session:', name, events);
}; };
const playEvents = async () => { const playEvents = async () => {
if (events.length === 0 || isPlayingRef.current) return; if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true); setIsPlaying(true);
isPlayingRef.current = true; isPlayingRef.current = true;
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); 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) if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*'); iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
} else { } else {
// Self-execution logic for guest
const el = document.querySelector(event.selector) as HTMLElement; const el = document.querySelector(event.selector) as HTMLElement;
if (el) { if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -361,6 +383,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
hoveredEventId, hoveredEventId,
setHoveredEventId, setHoveredEventId,
isClicking, isClicking,
isEnabled,
}} }}
> >
{children} {children}

View File

@@ -2,62 +2,65 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
import { useSearchParams } from 'next/navigation';
import { FeedbackOverlay } from '@mintel/next-feedback'; import { FeedbackOverlay } from '@mintel/next-feedback';
import { RecordModeOverlay } from './RecordModeOverlay'; import { RecordModeOverlay } from './RecordModeOverlay';
import { PickingHelper } from './PickingHelper'; import { PickingHelper } from './PickingHelper';
import { config } from '@/lib/config';
export function ToolCoordinator({ isEmbedded: isEmbeddedProp }: { isEmbedded?: boolean }) { interface ToolCoordinatorProps {
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive } = useRecordMode(); isEmbedded?: boolean;
const [isEmbedded, setIsEmbedded] = useState(false); feedbackEnabled?: boolean;
const [mounted, setMounted] = useState(false); }
useEffect(() => { export function ToolCoordinator({
setMounted(true); isEmbedded: isEmbeddedProp,
const embedded = feedbackEnabled = false,
isEmbeddedProp || }: ToolCoordinatorProps) {
window.location.search.includes('embedded=true') || const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
window.name === 'record-mode-iframe' || useRecordMode();
(window.self !== window.top); const [isEmbedded, setIsEmbedded] = useState(false);
setIsEmbedded(embedded); const [mounted, setMounted] = useState(false);
}, [isEmbeddedProp]);
useEffect(() => {
if (!mounted) return null; setMounted(true);
const embedded =
// ABSOLUTE Priority 1: Inside Iframe - ONLY Rendering PickingHelper isEmbeddedProp ||
if (isEmbedded) { window.location.search.includes('embedded=true') ||
return <PickingHelper />; window.name === 'record-mode-iframe' ||
} window.self !== window.top;
setIsEmbedded(embedded);
// ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED }, [isEmbeddedProp]);
if (isActive) {
return <RecordModeOverlay />; if (!mounted) return null;
}
// Nothing enabled → render nothing
// Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED if (!feedbackEnabled && !isEnabled) return null;
if (isFeedbackActive) {
return ( // Iframe → only PickingHelper
<FeedbackOverlay if (isEmbedded) return <PickingHelper />;
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)} // Record Mode active and enabled
/> if (isActive && isEnabled) return <RecordModeOverlay />;
);
} // Feedback active and enabled
if (isFeedbackActive && feedbackEnabled) {
// Baseline: Both toggle buttons (inactive state) return (
// Only render if neither is active to prevent any overlapping residues <FeedbackOverlay
// IMPORTANT: FeedbackOverlay must be rendered with isActive={false} to provide the toggle button, isActive={isFeedbackActive}
// but only if Record Mode is not active. onActiveChange={(active) => setIsFeedbackActive(active)}
return ( />
<div className="feedback-ui-ignore"> );
{config.feedbackEnabled && ( }
<FeedbackOverlay
isActive={false} // Baseline: toggle buttons
onActiveChange={(active) => setIsFeedbackActive(active)} return (
/> <div className="feedback-ui-ignore">
)} {feedbackEnabled && (
<RecordModeOverlay /> <FeedbackOverlay
</div> isActive={false}
); onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
{isEnabled && <RecordModeOverlay />}
</div>
);
} }

View File

@@ -15,6 +15,11 @@ function createConfig() {
const target = env.NEXT_PUBLIC_TARGET || env.TARGET; 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 { return {
env: env.NODE_ENV, env: env.NODE_ENV,
target, target,
@@ -23,6 +28,7 @@ function createConfig() {
isTesting: target === 'testing', isTesting: target === 'testing',
isDevelopment: target === 'development', isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED, feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL, gatekeeperUrl: env.GATEKEEPER_URL,
baseUrl: env.NEXT_PUBLIC_BASE_URL, baseUrl: env.NEXT_PUBLIC_BASE_URL,
@@ -144,6 +150,9 @@ export const config = {
get feedbackEnabled() { get feedbackEnabled() {
return getConfig().feedbackEnabled; return getConfig().feedbackEnabled;
}, },
get recordModeEnabled() {
return getConfig().recordModeEnabled;
},
get infraCMS() { get infraCMS() {
return getConfig().infraCMS; return getConfig().infraCMS;
}, },

View File

@@ -1,6 +1,18 @@
import { z } from 'zod'; import { z } from 'zod';
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils'; 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. * Environment variable schema.
* Extends the default Mintel environment schema. * Extends the default Mintel environment schema.
@@ -11,14 +23,9 @@ const envExtension = {
// Gatekeeper specifics not in base // Gatekeeper specifics not in base
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'), GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
GATEKEEPER_BYPASS_ENABLED: z.preprocess( GATEKEEPER_BYPASS_ENABLED: booleanSchema.default(false),
(val) => val === 'true' || val === true, NEXT_PUBLIC_FEEDBACK_ENABLED: booleanSchema.default(false),
z.boolean().default(false), NEXT_PUBLIC_RECORD_MODE_ENABLED: booleanSchema.default(false),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
INFRA_DIRECTUS_URL: z.string().url().optional(), INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(), INFRA_DIRECTUS_TOKEN: z.string().optional(),