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:
parent
ed7fa3f9a4
commit
ac106ed917
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
22
dashboards/tracker-web/src/components/product-switcher.tsx
Normal file
22
dashboards/tracker-web/src/components/product-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
48
dashboards/tracker-web/src/lib/product-context.tsx
Normal file
48
dashboards/tracker-web/src/lib/product-context.tsx
Normal 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;
|
||||
}
|
||||
@ -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()}` : '';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user