feat(tracker): add product switcher — filter items by any product

- product-config.ts: add getRequestProductId(req) + KNOWN_PRODUCTS
- product-context.tsx: client-side product selection context
- product-switcher.tsx: native select dropdown component
- tracker-client.ts: inject x-product-id header on all API calls
- proxy route: forward x-product-id header to platform-service
- providers.tsx: wrap with ProductProvider
- dashboard/layout.tsx: render ProductSwitcher in top nav
This commit is contained in:
saravanakumardb1 2026-02-28 14:15:18 -08:00
parent ed7fa3f9a4
commit ac106ed917
7 changed files with 109 additions and 2 deletions

View File

@ -28,6 +28,10 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ path: str
headers['Authorization'] = `Bearer ${tokenHeader}`;
}
// Forward product switcher header so platform-service filters by product
const productId = req.headers.get('x-product-id');
if (productId) headers['x-product-id'] = productId;
const fetchOptions: RequestInit = {
method: req.method,
headers,

View File

@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth-context';
import { ProductSwitcher } from '@/components/product-switcher';
const NAV_ITEMS = [
{ href: '/dashboard', label: 'Overview' },
@ -51,6 +52,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</nav>
</div>
<div className="flex items-center gap-4">
<ProductSwitcher />
<span className="text-sm text-muted-foreground">{user.email}</span>
<button
onClick={logout}

View File

@ -3,6 +3,7 @@
import { useEffect, type ReactNode } from 'react';
import { AuthProvider } from '@/lib/auth-context';
import { ThemeProvider } from '@/lib/theme-context';
import { ProductProvider } from '@/lib/product-context';
import { initTelemetry } from '@/lib/telemetry';
import { CSPostHogProvider } from '@/components/posthog-provider';
@ -15,7 +16,9 @@ export function Providers({ children }: { children: ReactNode }) {
return (
<CSPostHogProvider>
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
<ProductProvider>
<AuthProvider>{children}</AuthProvider>
</ProductProvider>
</ThemeProvider>
</CSPostHogProvider>
);

View File

@ -0,0 +1,22 @@
'use client';
import { useProduct } from '@/lib/product-context';
export function ProductSwitcher() {
const { productId, setProductId, products } = useProduct();
return (
<select
value={productId}
onChange={e => setProductId(e.target.value)}
className="h-8 rounded-md border border-border bg-card px-2 text-sm font-medium text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Select product"
>
{products.map(p => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
);
}

View File

@ -6,6 +6,7 @@
*/
import { loadProductIdentity } from '@bytelyst/config';
import type { NextRequest } from 'next/server';
const identity = loadProductIdentity();
@ -13,3 +14,19 @@ export const PRODUCT_ID = identity.productId;
export const DISPLAY_NAME = identity.displayName;
export const LICENSE_PREFIX = identity.licensePrefix;
export const PACKAGE_NAME = identity.packageName;
/**
* Extract productId from request header (set by client-side product switcher),
* falling back to the env-based PRODUCT_ID.
*/
export function getRequestProductId(req: NextRequest): string {
return req.headers.get('x-product-id') || PRODUCT_ID;
}
/** All known products in the ByteLyst ecosystem. */
export const KNOWN_PRODUCTS = [
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
] as const;

View File

@ -0,0 +1,48 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-config';
const STORAGE_KEY = 'tracker_selected_product';
interface ProductContextValue {
productId: string;
productName: string;
setProductId: (id: string) => void;
products: typeof KNOWN_PRODUCTS;
}
const ProductContext = createContext<ProductContextValue | null>(null);
function getInitialProduct(): string {
if (typeof window === 'undefined') return PRODUCT_ID;
return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID;
}
export function ProductProvider({ children }: { children: ReactNode }) {
const [productId, setProductIdState] = useState<string>(getInitialProduct);
const setProductId = useCallback((id: string) => {
setProductIdState(id);
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, id);
}
}, []);
const product = KNOWN_PRODUCTS.find(p => p.id === productId);
const productName = product?.name ?? productId;
return (
<ProductContext.Provider
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
>
{children}
</ProductContext.Provider>
);
}
export function useProduct(): ProductContextValue {
const ctx = useContext(ProductContext);
if (!ctx) throw new Error('useProduct must be used within <ProductProvider>');
return ctx;
}

View File

@ -57,7 +57,18 @@ const trackerApi = createApiClient({
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null),
});
const apiFetch = trackerApi.fetch;
/** Wrap apiFetch to inject x-product-id header from localStorage. */
function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const extra: Record<string, string> = {};
if (typeof window !== 'undefined') {
const pid = localStorage.getItem('tracker_selected_product');
if (pid) extra['x-product-id'] = pid;
}
return trackerApi.fetch<T>(path, {
...options,
headers: { ...extra, ...(options?.headers as Record<string, string>) },
});
}
export async function listItems(params?: Record<string, string>): Promise<ListItemsResponse> {
const qs = params ? `?${new URLSearchParams(params).toString()}` : '';