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}`;
|
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 = {
|
const fetchOptions: RequestInit = {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
|
import { ProductSwitcher } from '@/components/product-switcher';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/dashboard', label: 'Overview' },
|
{ href: '/dashboard', label: 'Overview' },
|
||||||
@ -51,6 +52,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<ProductSwitcher />
|
||||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
<span className="text-sm text-muted-foreground">{user.email}</span>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { AuthProvider } from '@/lib/auth-context';
|
import { AuthProvider } from '@/lib/auth-context';
|
||||||
import { ThemeProvider } from '@/lib/theme-context';
|
import { ThemeProvider } from '@/lib/theme-context';
|
||||||
|
import { ProductProvider } from '@/lib/product-context';
|
||||||
import { initTelemetry } from '@/lib/telemetry';
|
import { initTelemetry } from '@/lib/telemetry';
|
||||||
|
|
||||||
import { CSPostHogProvider } from '@/components/posthog-provider';
|
import { CSPostHogProvider } from '@/components/posthog-provider';
|
||||||
@ -15,7 +16,9 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<CSPostHogProvider>
|
<CSPostHogProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<ProductProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</ProductProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</CSPostHogProvider>
|
</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 { loadProductIdentity } from '@bytelyst/config';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const identity = loadProductIdentity();
|
const identity = loadProductIdentity();
|
||||||
|
|
||||||
@ -13,3 +14,19 @@ export const PRODUCT_ID = identity.productId;
|
|||||||
export const DISPLAY_NAME = identity.displayName;
|
export const DISPLAY_NAME = identity.displayName;
|
||||||
export const LICENSE_PREFIX = identity.licensePrefix;
|
export const LICENSE_PREFIX = identity.licensePrefix;
|
||||||
export const PACKAGE_NAME = identity.packageName;
|
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),
|
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> {
|
export async function listItems(params?: Record<string, string>): Promise<ListItemsResponse> {
|
||||||
const qs = params ? `?${new URLSearchParams(params).toString()}` : '';
|
const qs = params ? `?${new URLSearchParams(params).toString()}` : '';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user