import React, {
Suspense,
lazy,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
HashRouter as Router,
Routes,
Route,
Navigate,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import { Loader2Icon, LoaderCircleIcon } from 'lucide-react';
import { recomaze_ai_personalization_env } from './env';
import { canSeeSeoAnalytics } from './lib/seo/analytics-access';
import { Sidebar, SidebarShowButton } from './components/layout/sidebar';
import { seoOnlyHome, seoPageAllowed } from './components/layout/nav-config';
import { SeoBadgesProvider } from './context/seo-badges.context';
import { Topbar } from './components/layout/topbar';
import { MobileNav } from './components/layout/mobile-nav';
import { ConnectionNotice } from './components/layout/connection-notice';
import { navShownFromCookie, writeNavCookie } from './lib/navigation/nav-model';
import { TooltipProvider } from './components/ui/tooltip';
import { Toaster } from './components/ui/sonner';
import { getCreditBalance } from './service/credits/credits.service';
import { useAppStateContext } from './context/user.data.context';
import { isCopilotEnabled } from './service/copilot/access';
import { useAccessControl } from './hooks/useAccessControl';
import { signupMirrorRedirect } from './lib/signup-mirror/access';
import PreviewBanner from './components/PreviewBanner';
import { updatePreviewMode } from './service/user/user.service';
import VerificationModal from './components/VerificationModal';
import ErrorBoundary from './components/ErrorBoundary';
import { CopilotPanel } from './components/copilot/CopilotPanel';
import { useCopilot } from './context/copilot.context';
const Support = lazy(() => import('./pages/Support'));
const Settings = lazy(() => import('./pages/Settings'));
const SeoDashboard = lazy(() => import('./pages/seo/SeoDashboard'));
const Customization = lazy(() => import('./pages/Customization'));
const ImportProducts = lazy(() => import('./pages/ImportProducts'));
const PriceImport = lazy(() => import('./pages/PriceImport'));
const Pricing = lazy(() => import('./pages/Pricing'));
const AiVisibility = lazy(() => import('./pages/AiVisibility'));
const AiVisibilityContents = lazy(() => import('./pages/AiVisibilityContents'));
const AiVisibilityPageTemplates = lazy(
() => import('./pages/AiVisibilityPageTemplates')
);
const ProductImprovements = lazy(() => import('./pages/ProductImprovements'));
const AgentAnalytics = lazy(() => import('./pages/AgentAnalytics'));
const KnowledgeBase = lazy(() => import('./pages/KnowledgeBase'));
const ContentGenerator = lazy(() => import('./pages/ContentGenerator'));
const ContentChecker = lazy(() => import('./pages/ContentChecker'));
const OptimizedTextPages = lazy(() => import('./pages/OptimizedTextPages'));
const PromptTemplatesPage = lazy(() => import('./pages/PromptTemplatesPage'));
const Campaigns = lazy(() => import('./pages/Campaigns'));
const CampaignCreate = lazy(() => import('./pages/CampaignCreate'));
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'));
const CatalogQuality = lazy(() => import('./pages/CatalogQuality'));
const Connectors = lazy(() => import('./pages/Connectors'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Hq = lazy(() => import('./pages/Hq'));
const CompanyId = lazy(() => import('./pages/CompanyId'));
const SignupMirror = lazy(() => import('./pages/SignupMirror'));
const OnboardingChain = lazy(() => import('./pages/OnboardingChain'));
const UiKitchenSink = lazy(() => import('./pages/dev/UiKitchenSink'));
const SeoGeneralSettings = lazy(() => import('./pages/seo/SeoGeneralSettings'));
const SeoTitlesSettings = lazy(() => import('./pages/seo/SeoTitlesSettings'));
const SeoSitemapSettings = lazy(() => import('./pages/seo/SeoSitemapSettings'));
const SeoRedirects = lazy(() => import('./pages/seo/SeoRedirects'));
const SeoNotFound = lazy(() => import('./pages/seo/SeoNotFound'));
const SeoLinks = lazy(() => import('./pages/seo/SeoLinks'));
const SeoCheckup = lazy(() => import('./pages/seo/SeoCheckup'));
const SeoBulkEditor = lazy(() => import('./pages/seo/SeoBulkEditor'));
const SeoPerformance = lazy(() => import('./pages/seo/SeoPerformance'));
const SeoSetup = lazy(() => import('./pages/seo/SeoSetup'));
const SeoAnalytics = lazy(() => import('./pages/seo/SeoAnalytics'));
const SeoLocalSettings = lazy(() => import('./pages/seo/SeoLocalSettings'));
const SeoRoles = lazy(() => import('./pages/seo/SeoRoles'));
const LlmTxt = lazy(() => import('./pages/LlmTxt'));
const PageLoader = (): JSX.Element => (
);
const BARE_ROUTES = ['/signup-mirror', '/onboarding-chain'];
/**
* The pages a role given SEO pages alone can open: they work from the site,
* without the Recomaze account (no HQ, Copilot, Analytics or billing).
*/
const SEO_ONLY_PAGES: [string, React.ComponentType][] = [
['/seo', SeoDashboard],
['/seo/general', SeoGeneralSettings],
['/seo/titles', SeoTitlesSettings],
['/seo/sitemap', SeoSitemapSettings],
['/seo/redirects', SeoRedirects],
['/seo/404', SeoNotFound],
['/seo/links', SeoLinks],
['/seo/checkup', SeoCheckup],
['/seo/performance', SeoPerformance],
['/seo/bulk-editor', SeoBulkEditor],
['/seo/setup', SeoSetup],
['/seo/local', SeoLocalSettings],
];
// Everything that needs router hooks (useLocation / useNavigate) lives here,
// inside . This is also where the Signup Mirror resume redirect fires
// and where the full-screen onboarding routes bypass the app chrome.
const AppShell = (): JSX.Element => {
const { token, clientId, user, userFailed, setUser } = useAppStateContext();
const { canAccessAllRoutes, canSeeBulkUpload } = useAccessControl(user);
// Recomaze opens the SEO module account by account (Recomaze_Seo_Access).
const seoEnabled: boolean =
recomaze_ai_personalization_env?.seo_enabled ?? true;
const location = useLocation();
const navigate = useNavigate();
const resumedRef = useRef(false);
// A link from outside the app (the editor's "Fix in Copilot") opens the
// Copilot with ?copilot= in its composer. It is not sent from here: a link
// anyone can hand over must not spend credits or run tools on its own.
// The query is dropped so a reload does not put it back.
const [params, setParams] = useSearchParams();
const { draftCopilotTurn } = useCopilot();
const copilotPrompt = params.get('copilot');
useEffect(() => {
if (!copilotPrompt) return;
draftCopilotTurn(copilotPrompt);
const rest = new URLSearchParams(params);
rest.delete('copilot');
setParams(rest, { replace: true });
}, [copilotPrompt, draftCopilotTurn, params, setParams]);
const [navShown, setNavShown] = useState(() =>
navShownFromCookie(document.cookie)
);
const toggleNav = useCallback(() => {
setNavShown(shown => {
writeNavCookie(!shown);
return !shown;
});
}, []);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [credits, setCredits] = useState<{
used: number;
granted: number;
} | null>(null);
const email =
user?.email ?? recomaze_ai_personalization_env?.current_user_email;
useEffect(() => {
if (!token) return;
getCreditBalance(token, clientId).then(balance => {
setCredits(
typeof balance.granted === 'number' && balance.granted > 0
? { used: balance.used ?? 0, granted: balance.granted }
: null
);
});
}, [token, clientId]);
useEffect(() => {
if (resumedRef.current || !user) return;
if (location.pathname !== '/' && location.pathname !== '/hq') return;
const target = signupMirrorRedirect(user);
if (target) {
resumedRef.current = true;
navigate(target, { replace: true });
}
}, [user, location.pathname, navigate]);
const handleActivatePreview = async () => {
if (!token) return;
try {
await updatePreviewMode({ token, preview_mode: true });
if (user) setUser({ ...user, preview_mode: true });
} catch (error) {
console.error('Error enabling preview mode:', error);
}
};
const handleDeactivatePreview = async () => {
if (!token) return;
try {
await updatePreviewMode({ token, preview_mode: false });
if (user) setUser({ ...user, preview_mode: false });
} catch (error) {
console.error('Error disabling preview mode:', error);
}
};
// A fresh install lands on the app root while /me is still in flight, so
// rendering straight away painted the dashboard and only then jumped to the
// Mirror. Hold the shell until the account is known and the merchant sees
// one screen: the one they belong on.
// A /me that failed (the API down) holds nothing: the shell renders, and
// the connection notice says why the pages are empty.
const awaitingResumeDecision: boolean =
!!token &&
!user &&
!userFailed &&
(location.pathname === '/' || location.pathname === '/hq');
if (awaitingResumeDecision) {
return (
);
}
// Full-screen onboarding routes: no sidebar, no banners, no copilot. They
// still take the pinned shell, so they fill the viewport instead of leaving
// dead space under a short screen - and, since the admin stylesheet stops
// the document itself from scrolling, they carry their own scroll or a tall
// screen would be unreachable.
if (BARE_ROUTES.includes(location.pathname)) {
return (
}>
} />
} />
);
}
return (
{navShown ? (
) : (
)}
setMobileNavOpen(true)} />
{/* ``relative`` so an absolute box with no positioned ancestor of its
own (the ``sr-only`` input a custom checkbox hides, for one) is
contained, clipped and scrolled by ``main`` rather than falling
through to the shell, which is the containing block for
everything else (``contain: layout`` in the admin stylesheet).
The room under the last page element is a spacer (``after:``),
not padding: a sticky bar is held inside the scroll container's
content box, so with padding-bottom it floated 64px above the
edge with the page showing through underneath. */}
{/* ``@container``: the pages lay themselves out by the width of this
column, not the window's, since the WordPress menu and the
sidebar take a third of a laptop screen before a page starts. */}
{recomaze_ai_personalization_env?.seo_only ? (
}>
{SEO_ONLY_PAGES.filter(([path]) =>
seoPageAllowed(
path,
recomaze_ai_personalization_env.seo_caps
)
).map(([path, Page]) => (
} />
))}
}
/>
) : !canAccessAllRoutes ? (
}>
}>
) : (
}>
} />
} />
{/* The address this page had before it was named HQ. */}
}
/>
} />
} />
} />
} />
} />
} />
}
/>
}
/>
{/* llms.txt: a section of the SEO settings where the module
runs, its own page where Recomaze has not opened it. */}
) : (
)
}
/>
:
}
/>
}
/>
}
/>
}
/>
}
/>
} />
}
/>
} />
} />
{seoEnabled && (
<>
} />
}
/>
}
/>
}
/>
} />
} />
} />
} />
}
/>
}
/>
} />
) : (
)
}
/>
} />
} />
}
/>
}
/>
>
)}
} />
} />
} />
} />
} />
} />
} />
)}
{isCopilotEnabled(clientId) ? (
) : null}
);
};
const App = (): JSX.Element => {
const {
token,
clientId,
user,
plans,
setToken,
setClientId,
fetchUser,
fetchPlans,
setCanUseWpApi,
} = useAppStateContext();
useEffect(() => {
if (!token && recomaze_ai_personalization_env?.auth?.token)
setToken(recomaze_ai_personalization_env?.auth?.token);
if (!clientId && recomaze_ai_personalization_env?.auth?.client_id)
setClientId(recomaze_ai_personalization_env?.auth?.client_id);
if (!user && token) fetchUser();
if (!plans && token) fetchPlans();
if (!recomaze_ai_personalization_env?.can_use_wp_api) setCanUseWpApi(false);
}, [token, clientId]);
return (
);
};
export default App;