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
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

View File

@@ -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)

View File

@@ -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 (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head>
<style dangerouslySetInnerHTML={{
__html: `
/* Effectively Invisible Scrollbar */
::-webkit-scrollbar {
width: 2px;
height: 2px;
}
::-webkit-scrollbar-track {
background: 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;
}
`}} />
<style
dangerouslySetInnerHTML={{
__html: `
::-webkit-scrollbar { width: 2px; height: 2px; }
::-webkit-scrollbar-track { background: 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>
<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}>
<RecordModeProvider>
<RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals>
<JsonLd />
<Header />
@@ -140,7 +125,7 @@ export default async function LocaleLayout({
<Suspense fallback={null}>
<AnalyticsProvider />
</Suspense>
<ToolCoordinator />
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
</NextIntlClientProvider>
</body>

View File

@@ -26,6 +26,7 @@ interface RecordModeContextType {
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
isEnabled: boolean;
}
const RecordModeContext = createContext<RecordModeContextType | null>(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<RecordEvent[]>([]);
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<RecordEvent, 'id' | 'timestamp'>) => {
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<RecordEvent>) => {
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}

View File

@@ -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 <PickingHelper />;
}
// ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED
if (isActive) {
return <RecordModeOverlay />;
}
// Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED
if (isFeedbackActive) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => 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 (
<div className="feedback-ui-ignore">
{config.feedbackEnabled && (
<FeedbackOverlay
isActive={false}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
<RecordModeOverlay />
</div>
);
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 <PickingHelper />;
// Record Mode active and enabled
if (isActive && isEnabled) return <RecordModeOverlay />;
// Feedback active and enabled
if (isFeedbackActive && feedbackEnabled) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
);
}
// Baseline: toggle buttons
return (
<div className="feedback-ui-ignore">
{feedbackEnabled && (
<FeedbackOverlay
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;
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;
},

View File

@@ -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(),