/** * The plugin's navigation tree, consumed by the desktop sidebar, the mobile * sheet and the command palette. * * Same shape and vocabulary as recomaze-custom-platform's nav-config (AI HQ, * then the modules a merchant works in, then everything else), mapped onto * this app's HashRouter routes. Routes the plugin does not have (social * posts, the calendar, team, activity log) are left out rather than linked * to nowhere; the SEO module is new to both apps and starts with its * overview page. */ import { BookOpenIcon, BotIcon, BrainIcon, ChartColumnIcon, ClipboardCheckIcon, CornerDownRightIcon, CreditCardIcon, DownloadIcon, FileCodeIcon, FileTextIcon, FileWarningIcon, FingerprintIcon, HeadingIcon, HomeIcon, LayoutGridIcon, LayoutTemplateIcon, LifeBuoyIcon, Link2Icon, ListChecksIcon, MapIcon, MapPinIcon, MessageSquareIcon, PencilRulerIcon, PlugIcon, SearchCheckIcon, SearchIcon, SettingsIcon, Share2Icon, ShoppingCartIcon, SlidersHorizontalIcon, TagIcon, WandSparklesIcon, SparklesIcon, UsersIcon, } from 'lucide-react'; import { recomaze_ai_personalization_env } from '../../env'; import type { NavItem, NavSection } from '../../lib/navigation/nav-model'; import { CHECKUP_GROUPS } from '../../lib/seo/checkup-groups'; import { SEO_NAV_SECTIONS, typeSections, type SeoTypes, } from '../../lib/seo/nav-sections'; /** * The sections of a settings page as rows under it, each addressing its * `?section=`; the page opens on the default one when the URL names none. * The per-type sections follow, each group behind a caption row (an item * without a route), once the plugin has said which types exist. */ const seoSections = ( page: string, icon: NavItem['icon'], types?: SeoTypes ): NavItem[] => { const rows: NavItem[] = (SEO_NAV_SECTIONS[page]?.sections ?? []).map( section => ({ label: section.label, description: section.description, to: `${page}?section=${section.key}`, icon, }) ); let caption: string | undefined; (types ? typeSections(page, types) : []).forEach(section => { if (section.group && section.group !== caption) { caption = section.group; rows.push({ label: caption, icon }); } rows.push({ label: section.label, description: section.description, to: `${page}?section=${section.key}`, icon, }); }); return rows; }; const navSections = (types?: SeoTypes): NavSection[] => [ { // No header: HQ sits above the groups. title: '', menuKey: 'top', items: [ { label: 'AI HQ', description: 'Your day starts here: the numbers, the copilot and the setup you still owe.', to: '/hq', icon: HomeIcon, matchPaths: ['/', '/dashboard'], }, ], }, { title: 'Modules', menuKey: 'modules', items: [ { label: 'AI Search', description: 'Whether AI engines find you and what they say when a buyer asks.', to: '/ai-visibility', icon: SearchIcon, menuKey: 'ai-search', tag: 'AEO', children: [ { label: 'Visibility', description: 'Your weekly score, share of voice, and the brands being recommended instead of you.', to: '/ai-visibility', icon: SearchIcon, }, { label: 'Tracked prompts', description: 'The customer questions the scanner puts to AI engines every week, and who wins each one.', to: '/ai-visibility?tab=prompts', icon: MessageSquareIcon, }, { label: 'Content', description: 'Pages worth writing, ranked by the questions you are losing.', to: '/ai-visibility?tab=content', icon: FileTextIcon, }, { label: 'Chat', description: 'Ask the visibility copilot about your results.', to: '/ai-visibility?tab=chat', icon: BotIcon, }, ], }, { label: 'Content Studio', description: 'Everything Recomaze writes for you, and the briefs behind it.', to: '/ai-visibility/contents', icon: FileTextIcon, menuKey: 'content-studio', matchPaths: [ '/content-generator/optimized-pages', '/content-generator/prompt-templates', ], children: [ { label: 'Library', description: 'Every page Recomaze has written, recommended drafts and published ones together.', to: '/ai-visibility/contents', icon: FileTextIcon, }, { label: 'Page templates', description: 'The briefs Recomaze writes pages from, ready to replay for a new category.', to: '/ai-visibility/page-templates', icon: LayoutTemplateIcon, }, { label: 'Optimized pages', description: 'Existing pages rewritten for AI search, side by side.', to: '/content-generator/optimized-pages', icon: FileTextIcon, }, { label: 'Prompt templates', description: 'The instructions behind the generators, editable per content type.', to: '/content-generator/prompt-templates', icon: ListChecksIcon, }, ], }, { label: 'Commerce', description: 'How well your catalog reads to an engine, and fixing it.', to: '/catalog-quality', icon: ShoppingCartIcon, menuKey: 'commerce', tag: 'GEO', matchPaths: [ '/content-generator', '/product-improvements', '/import', '/prices', ], children: [ { label: 'Catalog quality', description: "Each product's title and description scored, with the issues found and rewrites to copy.", to: '/catalog-quality', icon: ShoppingCartIcon, }, { label: 'Product content', description: 'Upload a product CSV and get titles, descriptions, tags and FAQs back to re-import.', to: '/content-generator', icon: SparklesIcon, }, { label: 'Content checker', description: 'Score a title or description for AI-search readiness before you ship it.', to: '/content-generator/content-checker', icon: ClipboardCheckIcon, }, { label: 'Bulk update', description: 'Apply improved titles, descriptions and tags to the products the audit flagged.', to: '/product-improvements', icon: PencilRulerIcon, }, { label: 'Import products', description: 'Send the WooCommerce catalog to Recomaze again, in the background.', to: '/import', icon: DownloadIcon, }, { label: 'Prices from a file', description: 'A spreadsheet of SKUs and prices, previewed before anything changes and undoable after.', to: '/prices', icon: TagIcon, }, ], }, { label: 'SEO', description: 'Titles, descriptions, schema, sitemaps and redirects, written and fixed by Recomaze.', to: '/seo', icon: SearchCheckIcon, menuKey: 'seo', children: [ { label: 'Dashboard', description: 'The modules this site runs, and import & export.', to: '/seo', icon: LayoutGridIcon, }, { label: 'Set SEO up', description: 'Six steps over what a site says once: what to bring over, who it belongs to, what may be indexed and read by AI.', to: '/seo/setup', icon: WandSparklesIcon, }, { label: 'Checkup', description: 'What stands between the site and search engines, and what fixed it.', to: '/seo/checkup', icon: ClipboardCheckIcon, menuKey: 'seo-checkup', children: CHECKUP_GROUPS.map(group => ({ label: group.label, description: group.description, to: `/seo/checkup?group=${group.key}`, icon: group.icon, })), }, { label: 'Analytics', description: 'Clicks, impressions and positions from Google Search Console, by query and by page.', to: '/seo/analytics', icon: ChartColumnIcon, }, { label: 'General settings', description: 'Links, breadcrumbs, redirects, images, webmaster codes, llms.txt, robots.txt and the shop.', to: '/seo/general', icon: SlidersHorizontalIcon, menuKey: 'seo-general', children: seoSections('/seo/general', SlidersHorizontalIcon), }, { label: 'Titles & meta', description: 'How the title and description of every page are built.', to: '/seo/titles', icon: HeadingIcon, menuKey: 'seo-titles', children: seoSections('/seo/titles', HeadingIcon, types), }, { label: 'Sitemap', description: 'What the XML sitemap lists and how it is split.', to: '/seo/sitemap', icon: MapIcon, menuKey: 'seo-sitemap', children: seoSections('/seo/sitemap', MapIcon, types), }, { label: 'Redirects', description: 'Old addresses sent to new ones.', to: '/seo/redirects', icon: CornerDownRightIcon, }, { label: '404 monitor', description: 'Addresses that do not exist but keep being asked for.', to: '/seo/404', icon: FileWarningIcon, }, { label: 'Links', description: 'Link counts, orphan pages and broken links.', to: '/seo/links', icon: Link2Icon, }, { label: 'Local SEO', description: 'The business, its address, hours and map.', to: '/seo/local', icon: MapPinIcon, menuKey: 'seo-local', children: seoSections('/seo/local', MapPinIcon), }, { label: 'Role manager', description: 'Which roles may touch which part of SEO.', to: '/seo/roles', icon: UsersIcon, }, ], }, { label: 'Sales', description: 'The storefront chat, what it sold, and what it was told.', to: '/agent-analytics?tab=overview', icon: MessageSquareIcon, menuKey: 'sales', children: [ { label: 'Reports', description: 'The headline numbers and the funnel down to cart, for the chat agent.', to: '/agent-analytics?tab=overview', icon: FileTextIcon, }, { label: 'Analytics', description: 'Sessions, intents and outcomes over time.', to: '/agent-analytics?tab=analytics', icon: ChartColumnIcon, }, { label: 'Conversations', description: 'Every chat session as a row with outcome, intent, clicks and carts; open one to read it.', to: '/agent-analytics?tab=conversations', icon: MessageSquareIcon, }, { label: 'Campaigns', description: 'Every campaign boosting chosen categories in chat, with impressions, clicks and CTR.', to: '/campaigns', icon: Share2Icon, }, { label: 'AI chatbot', description: 'Your storefront chat widget: colors, logo, position and greetings, with a live preview.', to: '/customization', icon: BotIcon, }, ], }, { label: 'Customer Success', description: 'Where the agent came up short, and what to teach it.', to: '/agent-analytics?tab=gaps', icon: LifeBuoyIcon, menuKey: 'support', children: [ { label: 'Data gaps', description: 'Turns the agent could not answer, grouped by cause, each with a suggested fix.', to: '/agent-analytics?tab=gaps', icon: SearchIcon, }, { label: 'Insights', description: 'What the conversations say about your buyers.', to: '/agent-analytics?tab=insights', icon: SparklesIcon, }, ], }, ], }, { title: 'Everything else', menuKey: 'rest', items: [ { label: 'AI Brain', description: 'What Recomaze knows about you, and where it learned it.', to: '/company-id', icon: BrainIcon, menuKey: 'brain', children: [ { label: 'Company AI Profile', description: 'Your business as a record AI engines can read, with a completeness score.', to: '/company-id', icon: FingerprintIcon, }, { label: 'Knowledge Base', description: 'The documents your agent answers from, auto-imported plus your own uploads.', to: '/knowledge-base', icon: BookOpenIcon, }, { label: 'llms.txt', description: 'The file AI crawlers read your site from, and who has been fetching it.', to: '/seo/general?section=llms', icon: FileCodeIcon, }, ], }, { label: 'Settings', description: 'Your account, your site, and where to get help.', to: '/settings', icon: SettingsIcon, menuKey: 'settings', children: [ { label: 'Account', description: 'Your Recomaze connection and account-level settings.', to: '/settings', icon: SettingsIcon, }, { label: 'Connectors', description: 'How Recomaze publishes to this site: endpoint, post type and delivery log.', to: '/connectors', icon: PlugIcon, }, { label: 'Docs & Support', description: 'Guides, and how to reach us.', to: '/support', icon: LifeBuoyIcon, }, ], }, ], }, ]; /** Plans & Billing, the one entry at the foot of the nav next to the credits. */ export const BILLING_NAV_ITEM: NavItem = { label: 'Plans & Billing', description: "Your plan, this month's credits and the invoices.", to: '/pricing', icon: CreditCardIcon, }; /** The SEO pages that belong to one module, and the module. */ /** * The capability each SEO page needs; the rest of SEO needs the settings * one. The role manager hands them out. */ const SEO_PAGE_CAPS: Record = { '/seo/redirects': 'recomaze_seo_redirects', '/seo/404': 'recomaze_seo_404', '/seo/sitemap': 'recomaze_seo_sitemap', '/seo/analytics': 'recomaze_seo_analytics', }; /** * Whether this user may open an SEO page: with no capability list (an older * page load) nothing is hidden, the REST API refuses what it must. * * @param {string} to - The route. * @param {string[] | undefined} caps - The user's SEO capabilities. * @returns {boolean} True when the page opens for them. */ export const seoPageAllowed = ( to: string, caps: string[] | undefined ): boolean => { if (!caps || !to.startsWith('/seo')) { return true; } const own = SEO_PAGE_CAPS[to.split('?')[0]]; if (to.split('?')[0] === '/seo/sitemap') { return caps.includes(own) || caps.includes('recomaze_seo_settings'); } return caps.includes(own ?? 'recomaze_seo_settings'); }; /** * The SEO pages that need more than the site: Analytics reads the Recomaze * account, the role manager is for whoever manages the site. A role given * SEO pages alone (the plugin page without the account) does not get them. */ const ACCOUNT_SEO_PAGES: ReadonlySet = new Set([ '/seo/analytics', '/seo/roles', ]); /** Where llms.txt is set while the SEO module runs. */ const LLMS_SETTINGS = '/seo/general?section=llms'; const MODULE_PAGES: Record = { '/seo/redirects': 'redirects', '/seo/404': 'monitor_404', '/seo/links': 'links', '/seo/local': 'local_seo', '/seo/roles': 'role_manager', }; /** * The pages that work on the shop's products: without WooCommerce there is * no catalog to import, audit, feed, price or rewrite, so they leave the * menu, the Commerce group with them. */ const SHOP_PAGES: ReadonlySet = new Set([ '/catalog-quality', '/content-generator', '/product-improvements', '/import', '/prices', '/seo/general?section=woocommerce', ]); /** * Where the plugin page opens for a role given SEO pages alone: the first * of them it may use. * * @param {string[] | undefined} caps - The user's SEO capabilities. * @returns {string} The route. */ export const seoOnlyHome = (caps: string[] | undefined): string => ['/seo', '/seo/redirects', '/seo/404', '/seo/sitemap'].find(to => seoPageAllowed(to, caps) ) ?? '/seo'; /** What decides which entries a merchant sees. */ export interface VisibleNavOptions { /** Whether the bulk product update is on the merchant's plan. */ canSeeBulkUpload?: boolean; /** Whether this site may open SEO → Analytics (the Google app is unverified). */ canSeeSeoAnalytics?: boolean; /** Whether WooCommerce runs here; without it the shop's pages leave the menu. */ hasWooCommerce?: boolean; /** Whether the SEO setup is behind this site; done, the steps leave the menu. */ seoSetupDone?: boolean; /** The site's post types and taxonomies, for the per-type SEO sections. */ seoTypes?: SeoTypes; /** Which SEO modules are on; a module that is off takes its page along. */ seoModules?: Record; /** The user's SEO capabilities, which open the SEO pages. */ seoCaps?: string[]; /** The plugin page opened for its SEO pages alone, without the Recomaze account. */ seoOnly?: boolean; /** Whether Recomaze opened the SEO module to this account. */ seoEnabled?: boolean; } /** * The nav a merchant can see: the tree with the entries their plan does not * include removed, and the foot items. * * @param {VisibleNavOptions} options - Gating inputs. * @returns {{ sections: NavSection[]; utilityItems: NavItem[] }} */ export function buildVisibleNav({ canSeeBulkUpload = false, canSeeSeoAnalytics = false, hasWooCommerce = Boolean(recomaze_ai_personalization_env?.has_woocommerce), seoSetupDone = false, seoTypes, seoModules, seoCaps = recomaze_ai_personalization_env?.seo_caps, seoOnly = Boolean(recomaze_ai_personalization_env?.seo_only), seoEnabled = recomaze_ai_personalization_env?.seo_enabled ?? true, }: VisibleNavOptions): { sections: NavSection[]; utilityItems: NavItem[]; } { const hidden = (to: string | undefined): boolean => (!seoEnabled && to !== undefined && to.startsWith('/seo')) || (to !== undefined && !seoPageAllowed(to, seoCaps)) || (seoOnly && to !== undefined && ACCOUNT_SEO_PAGES.has(to.split('?')[0])) || (!canSeeBulkUpload && to === '/product-improvements') || (!canSeeSeoAnalytics && to === '/seo/analytics') || (!hasWooCommerce && to !== undefined && SHOP_PAGES.has(to)) || (seoSetupDone && to === '/seo/setup') || (to !== undefined && MODULE_PAGES[to] !== undefined && seoModules?.[MODULE_PAGES[to]] === false); // Rows sit two and three levels deep (SEO → General settings → WooCommerce), // so the gate runs on every level. A group whose own page the role may // not open (SEO, for a role given Redirects alone) still leads to the // pages under it that it may. const visible = (items: NavItem[]): NavItem[] => items.flatMap(item => { const children = item.children ? visible(item.children) : undefined; if (!hidden(item.to)) { return [children ? { ...item, children } : item]; } const reachable = children?.find(child => child.to !== undefined); return item.to !== undefined && !seoPageAllowed(item.to, seoCaps) && reachable ? [{ ...item, to: reachable.to, children }] : []; }); // The content checker scores blog posts too: without a shop it leaves // Commerce, which goes with the shop, for Content Studio. const checker: NavItem = { label: 'Content checker', description: 'Score a blog post, or a title and description, for AI-search readiness before you publish it.', to: '/content-generator/content-checker', icon: ClipboardCheckIcon, }; const withChecker = (item: NavItem): NavItem => !hasWooCommerce && item.menuKey === 'content-studio' ? { ...item, matchPaths: [...(item.matchPaths ?? []), checker.to as string], children: [...(item.children ?? []), checker], } : item; // Without the module llms.txt keeps a page of its own, as before it. const ownLlms = (items: NavItem[]): NavItem[] => items.map(item => { const to = !seoEnabled && item.to === LLMS_SETTINGS ? '/llm' : item.to; return item.children ? { ...item, to, children: ownLlms(item.children) } : { ...item, to }; }); const sections = navSections(seoTypes).map(section => ({ ...section, items: visible(ownLlms(section.items)).map(withChecker), })); if (seoOnly) { // HQ, the modules that call Recomaze and the billing need the account: // the SEO group, as far as the role reaches, is the whole menu. const seo = sections .flatMap(section => section.items) .filter(item => item.menuKey === 'seo'); return { sections: [{ title: '', items: seo }], utilityItems: [] }; } return { sections, utilityItems: [BILLING_NAV_ITEM] }; }