feat(01-code-splitting): add feature gating with ALWRITY_ENABLED_FEATURES
- Create FeatureRoute.tsx wrapper component for route-level feature gating - Add FEATURE_KEYS constant map to demoMode.ts for type-safe feature references - Wrap 47 feature-specific routes with <FeatureRoute> in App.tsx - Core routes (dashboard, billing, pricing, auth callbacks) remain ungated - Disabled features redirect to /dashboard and never load their lazy chunks - Main bundle: +259 bytes (FeatureRoute is a lightweight component) Closes Phase 1 Plan 01-02
This commit is contained in:
@@ -11,6 +11,7 @@ import TokenInstaller from './components/App/TokenInstaller';
|
||||
import { ConditionalCopilotKit, AuthenticatedCopilotWrapper } from './components/App/CopilotWrappers';
|
||||
import Landing from './components/Landing/Landing';
|
||||
import LazyLoadingFallback from './components/shared/LazyLoadingFallback';
|
||||
import FeatureRoute from './components/shared/FeatureRoute';
|
||||
|
||||
// ─── Lazy loaded route components ───────────────────────────────────────────
|
||||
// Default exports
|
||||
@@ -187,62 +188,63 @@ const App: React.FC = () => {
|
||||
<Route path="/error-test" element={<ErrorBoundaryTest />} />
|
||||
)}
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
|
||||
<Route path="/story-projects" element={<ProtectedRoute><StoryProjectList /></ProtectedRoute>} />
|
||||
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
|
||||
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
|
||||
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio" element={<ProtectedRoute><VideoStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/create" element={<ProtectedRoute><CreateVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/avatar" element={<ProtectedRoute><AvatarVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/enhance" element={<ProtectedRoute><EnhanceVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/extend" element={<ProtectedRoute><ExtendVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/edit" element={<ProtectedRoute><EditVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/transform" element={<ProtectedRoute><TransformVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/social" element={<ProtectedRoute><SocialVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FaceSwap /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-translate" element={<ProtectedRoute><VideoTranslate /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><VideoBackgroundRemover /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><AddAudioToVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/library" element={<ProtectedRoute><LibraryVideo /></ProtectedRoute>} />
|
||||
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/face-swap" element={<ProtectedRoute><FaceSwapStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/compress" element={<ProtectedRoute><CompressionStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/processing" element={<ProtectedRoute><ImageProcessingStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><ProductPhotoshootStudio /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/animation" element={<ProtectedRoute><ProductAnimationStudio /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/video" element={<ProtectedRoute><ProductVideoStudio /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/avatar" element={<ProtectedRoute><ProductAvatarStudio /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><FeatureRoute feature="content-planning"><ContentPlanningDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FeatureRoute feature="social"><FacebookWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><FeatureRoute feature="social"><LinkedInWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><FeatureRoute feature="blog_writer"><BlogWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/story-writer" element={<ProtectedRoute><FeatureRoute feature="story"><StoryWriter /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/story-projects" element={<ProtectedRoute><FeatureRoute feature="story"><StoryProjectList /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/youtube-creator" element={<ProtectedRoute><FeatureRoute feature="youtube"><YouTubeCreator /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/podcast-maker" element={<ProtectedRoute><FeatureRoute feature="podcast"><PodcastDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-studio" element={<ProtectedRoute><FeatureRoute feature="image"><ImageStudioDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio" element={<ProtectedRoute><FeatureRoute feature="video"><VideoStudioDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/create" element={<ProtectedRoute><FeatureRoute feature="video"><CreateVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/avatar" element={<ProtectedRoute><FeatureRoute feature="video"><AvatarVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/enhance" element={<ProtectedRoute><FeatureRoute feature="video"><EnhanceVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/extend" element={<ProtectedRoute><FeatureRoute feature="video"><ExtendVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/edit" element={<ProtectedRoute><FeatureRoute feature="video"><EditVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/transform" element={<ProtectedRoute><FeatureRoute feature="video"><TransformVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/social" element={<ProtectedRoute><FeatureRoute feature="video"><SocialVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FeatureRoute feature="video"><FaceSwap /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-translate" element={<ProtectedRoute><FeatureRoute feature="video"><VideoTranslate /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><FeatureRoute feature="video"><VideoBackgroundRemover /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><FeatureRoute feature="video"><AddAudioToVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/video-studio/library" element={<ProtectedRoute><FeatureRoute feature="video"><LibraryVideo /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-generator" element={<ProtectedRoute><FeatureRoute feature="image"><CreateStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-editor" element={<ProtectedRoute><FeatureRoute feature="image"><EditStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><FeatureRoute feature="image"><UpscaleStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><FeatureRoute feature="image"><ControlStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-studio/face-swap" element={<ProtectedRoute><FeatureRoute feature="image"><FaceSwapStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-studio/compress" element={<ProtectedRoute><FeatureRoute feature="image"><CompressionStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-studio/processing" element={<ProtectedRoute><FeatureRoute feature="image"><ImageProcessingStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><FeatureRoute feature="image"><SocialOptimizer /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><FeatureRoute feature="asset-library"><AssetLibrary /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator" element={<ProtectedRoute><FeatureRoute feature="campaign"><ProductMarketingDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><FeatureRoute feature="campaign"><ProductPhotoshootStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/animation" element={<ProtectedRoute><FeatureRoute feature="campaign"><ProductAnimationStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/video" element={<ProtectedRoute><FeatureRoute feature="campaign"><ProductVideoStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator/avatar" element={<ProtectedRoute><FeatureRoute feature="campaign"><ProductAvatarStudio /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><FeatureRoute feature="scheduler"><SchedulerDashboard /></FeatureRoute></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
<Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} />
|
||||
<Route path="/team-activity" element={<ProtectedRoute><TeamActivityPage /></ProtectedRoute>} />
|
||||
<Route path="/stripe-disputes" element={<ProtectedRoute><StripeDisputesDashboard /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/research-test" element={<ResearchDashboard />} />
|
||||
<Route path="/research-dashboard" element={<ResearchDashboard />} />
|
||||
<Route path="/alwrity-researcher" element={<ResearchDashboard />} />
|
||||
<Route path="/intent-research" element={<IntentResearchTest />} />
|
||||
<Route path="/wix-test" element={<WixTestPage />} />
|
||||
<Route path="/wix-test-direct" element={<WixTestPage />} />
|
||||
<Route path="/research-test" element={<FeatureRoute feature="research"><ResearchDashboard /></FeatureRoute>} />
|
||||
<Route path="/research-dashboard" element={<FeatureRoute feature="research"><ResearchDashboard /></FeatureRoute>} />
|
||||
<Route path="/alwrity-researcher" element={<FeatureRoute feature="research"><ResearchDashboard /></FeatureRoute>} />
|
||||
<Route path="/intent-research" element={<FeatureRoute feature="research"><IntentResearchTest /></FeatureRoute>} />
|
||||
<Route path="/wix-test" element={<FeatureRoute feature="wix"><WixTestPage /></FeatureRoute>} />
|
||||
<Route path="/wix-test-direct" element={<FeatureRoute feature="wix"><WixTestPage /></FeatureRoute>} />
|
||||
{/* Auth callbacks — always accessible (needed for OAuth flow) */}
|
||||
<Route path="/wix/callback" element={<WixCallbackPage />} />
|
||||
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
<Route path="/bing/callback" element={<BingCallbackPage />} />
|
||||
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
|
||||
<Route path="/bing-analytics-storage" element={<ProtectedRoute><FeatureRoute feature="bing"><BingAnalyticsStorage /></FeatureRoute></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ConditionalCopilotKit>
|
||||
|
||||
34
frontend/src/components/shared/FeatureRoute.tsx
Normal file
34
frontend/src/components/shared/FeatureRoute.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { isFeatureEnabled } from '../../utils/demoMode';
|
||||
|
||||
interface FeatureRouteProps {
|
||||
feature: string;
|
||||
children: React.ReactNode;
|
||||
/** Where to redirect if feature is disabled (default: /dashboard) */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that checks if a feature is enabled via ALWRITY_ENABLED_FEATURES.
|
||||
* If disabled, redirects to the fallback route and the lazy chunk never loads.
|
||||
*
|
||||
* Usage:
|
||||
* <Route path="/blog-writer" element={
|
||||
* <ProtectedRoute>
|
||||
* <FeatureRoute feature="blog_writer"><BlogWriter /></FeatureRoute>
|
||||
* </ProtectedRoute>
|
||||
* } />
|
||||
*/
|
||||
const FeatureRoute: React.FC<FeatureRouteProps> = ({
|
||||
feature,
|
||||
children,
|
||||
redirectTo = '/dashboard'
|
||||
}) => {
|
||||
if (!isFeatureEnabled(feature)) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default FeatureRoute;
|
||||
@@ -2,9 +2,36 @@
|
||||
* Consolidated feature mode detection utilities.
|
||||
*
|
||||
* Primary env var: REACT_APP_ENABLED_FEATURES
|
||||
* Format: "all" or comma-separated: "podcast,core"
|
||||
* Format: "all" or comma-separated: "podcast,blog_writer"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Known feature keys for route gating with ALWRITY_ENABLED_FEATURES.
|
||||
* These match the backend FEATURE_GROUPS in alwrity_utils/feature_registry.py.
|
||||
*
|
||||
* Usage: isFeatureEnabled(FEATURE_KEYS.PODCAST) → true/false
|
||||
*/
|
||||
export const FEATURE_KEYS = {
|
||||
CORE: 'core',
|
||||
SEO: 'seo',
|
||||
CONTENT_PLANNING: 'content-planning',
|
||||
SOCIAL: 'social',
|
||||
BLOG_WRITER: 'blog_writer',
|
||||
STORY: 'story',
|
||||
YOUTUBE: 'youtube',
|
||||
PODCAST: 'podcast',
|
||||
VIDEO: 'video',
|
||||
IMAGE: 'image',
|
||||
CAMPAIGN: 'campaign',
|
||||
SCHEDULER: 'scheduler',
|
||||
RESEARCH: 'research',
|
||||
WIX: 'wix',
|
||||
BING: 'bing',
|
||||
ASSET_LIBRARY: 'asset-library',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = typeof FEATURE_KEYS[keyof typeof FEATURE_KEYS];
|
||||
|
||||
const PRIMARY_STORAGE_KEY = 'enabled_features';
|
||||
const PRIMARY_ENV_KEY = 'REACT_APP_ENABLED_FEATURES';
|
||||
|
||||
@@ -16,12 +43,10 @@ let cachedFeatures: Set<string> | null = null;
|
||||
* Returns a Set of enabled feature names.
|
||||
*/
|
||||
export function getEnabledFeatures(): Set<string> {
|
||||
// Return cached value if available
|
||||
if (cachedFeatures) {
|
||||
return cachedFeatures;
|
||||
}
|
||||
|
||||
// Check localStorage first
|
||||
const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY);
|
||||
if (storageValue) {
|
||||
const features = storageValue.toLowerCase().split(',').map(f => f.trim());
|
||||
@@ -33,7 +58,6 @@ export function getEnabledFeatures(): Set<string> {
|
||||
return cachedFeatures;
|
||||
}
|
||||
|
||||
// Check environment variable
|
||||
const envValue = process.env[PRIMARY_ENV_KEY];
|
||||
if (envValue) {
|
||||
const features = envValue.toLowerCase().split(',').map(f => f.trim());
|
||||
@@ -45,7 +69,6 @@ export function getEnabledFeatures(): Set<string> {
|
||||
return cachedFeatures;
|
||||
}
|
||||
|
||||
// Default: all features enabled
|
||||
cachedFeatures = new Set(['all']);
|
||||
return cachedFeatures;
|
||||
}
|
||||
@@ -58,20 +81,55 @@ export function isFeatureEnabled(feature: string): boolean {
|
||||
return enabled.has('all') || enabled.has(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in feature-only mode (not "all").
|
||||
* Returns true when a specific subset of features is enabled.
|
||||
*/
|
||||
export function isFeatureOnlyMode(): boolean {
|
||||
const enabled = getEnabledFeatures();
|
||||
return !enabled.has('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if podcast-only mode is enabled.
|
||||
* Returns true when podcast is enabled but not "all".
|
||||
*/
|
||||
export function isPodcastOnlyDemoMode(): boolean {
|
||||
const enabled = getEnabledFeatures();
|
||||
return enabled.has('podcast') && !enabled.has('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the single enabled feature name, or null if multiple or full mode.
|
||||
*/
|
||||
export function getSingleFeature(): string | null {
|
||||
const enabled = getEnabledFeatures();
|
||||
if (enabled.has('all')) return null;
|
||||
if (enabled.size === 1) return [...enabled][0];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default landing route based on enabled features.
|
||||
*/
|
||||
export function getDefaultLandingRoute(): string {
|
||||
const enabled = getEnabledFeatures();
|
||||
if (enabled.has('all')) return '/dashboard';
|
||||
const singleFeature = getSingleFeature();
|
||||
if (singleFeature) {
|
||||
const routeMap: Record<string, string> = {
|
||||
'podcast': '/podcast-maker',
|
||||
'blog_writer': '/blog-writer',
|
||||
};
|
||||
return routeMap[singleFeature] || '/dashboard';
|
||||
}
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app should skip onboarding.
|
||||
* Returns true in podcast-only mode.
|
||||
* Returns true in feature-only mode.
|
||||
*/
|
||||
export function shouldSkipOnboarding(): boolean {
|
||||
const enabled = getEnabledFeatures();
|
||||
return enabled.has('podcast') || !enabled.has('all');
|
||||
return !enabled.has('all');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user