feat(tracker-web): make product switcher generic — configurable list + auto-hide

Steps toward a tenant-neutral platform (not hardcoded to the ByteLyst products):

- The selectable product list is now configurable via NEXT_PUBLIC_PRODUCTS
  (JSON array of { id, name, icon? }), defaulting to the built-in set. A pure,
  defensive parser (parseProductsEnv) falls back to the default on any malformed
  value so a bad env can never blank the switcher.
- The sidebar switcher auto-hides when there is <= 1 product, so a solo / freelance
  / single-tenant deployment shows no switcher clutter.
- Dedupe: the server product-config now re-exports the single client-safe list
  instead of keeping a second hardcoded copy.

NOTE: true per-user "only your projects" scoping + server-side tenant
authorization still requires an ownership/membership model that does not exist
yet (ProductDoc has no owner/members; products are a global registry). That is a
deliberate, separate effort needing a product decision and is not included here.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-06-01 16:27:14 -07:00
parent 705d8e8eaa
commit 2c4357b71b
7 changed files with 181 additions and 13 deletions

View File

@ -7,6 +7,12 @@
PRODUCT_ID=lysnrai
NEXT_PUBLIC_PRODUCT_ID=lysnrai
# Optional: override the selectable product/project list shown in the sidebar
# switcher. JSON array of { id, name, icon? }. Leave unset to use the built-in
# default. With one entry the switcher auto-hides (single-tenant deployments).
# Example: NEXT_PUBLIC_PRODUCTS=[{"id":"acme","name":"Acme App"}]
NEXT_PUBLIC_PRODUCTS=
# ── Microservice URLs (consolidated platform-service) ──
PLATFORM_SERVICE_URL=http://localhost:4003
PLATFORM_API_URL=http://localhost:4003

View File

@ -0,0 +1,51 @@
/**
* Configurable product list (generic-platform support): the NEXT_PUBLIC_PRODUCTS
* override parser must be defensive so a bad env value can never blank the switcher.
*/
import { describe, it, expect } from 'vitest';
import { parseProductsEnv, DEFAULT_PRODUCTS, type Product } from '@/lib/product-constants';
const fallback: readonly Product[] = [{ id: 'only', name: 'Only' }];
describe('parseProductsEnv', () => {
it('returns the fallback for empty / missing input', () => {
expect(parseProductsEnv(undefined, fallback)).toBe(fallback);
expect(parseProductsEnv('', fallback)).toBe(fallback);
expect(parseProductsEnv(' ', fallback)).toBe(fallback);
});
it('parses a valid JSON array of products', () => {
const raw = JSON.stringify([
{ id: 'acme', name: 'Acme App', icon: 'Box' },
{ id: 'beta', name: 'Beta' },
]);
expect(parseProductsEnv(raw, fallback)).toEqual([
{ id: 'acme', name: 'Acme App', icon: 'Box' },
{ id: 'beta', name: 'Beta' },
]);
});
it('defaults a missing name to the id and drops the icon when absent', () => {
expect(parseProductsEnv(JSON.stringify([{ id: 'x' }]), fallback)).toEqual([
{ id: 'x', name: 'x' },
]);
});
it('skips malformed entries but keeps valid ones', () => {
const raw = JSON.stringify([{ id: '' }, null, 42, { name: 'no id' }, { id: 'ok', name: 'OK' }]);
expect(parseProductsEnv(raw, fallback)).toEqual([{ id: 'ok', name: 'OK' }]);
});
it('falls back on non-array JSON, invalid JSON, or an all-invalid array', () => {
expect(parseProductsEnv('{"id":"x"}', fallback)).toBe(fallback); // object, not array
expect(parseProductsEnv('not json', fallback)).toBe(fallback);
expect(parseProductsEnv(JSON.stringify([{ bad: true }]), fallback)).toBe(fallback);
});
it('the built-in default is non-empty with unique ids', () => {
expect(DEFAULT_PRODUCTS.length).toBeGreaterThan(0);
const ids = DEFAULT_PRODUCTS.map(p => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View File

@ -0,0 +1,60 @@
// @vitest-environment happy-dom
/**
* ProductSwitcher auto-hides for single-tenant deployments and lists the
* configured products otherwise.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Product } from '@/lib/product-constants';
const useProduct = vi.fn();
vi.mock('@/lib/product-context', () => ({ useProduct: () => useProduct() }));
import { ProductSwitcher } from '@/components/product-switcher';
beforeAll(() => {
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
});
function render(products: readonly Product[]): { container: HTMLDivElement; root: Root } {
useProduct.mockReturnValue({
productId: products[0]?.id ?? 'x',
setProductId: vi.fn(),
products,
});
const container = document.createElement('div');
document.body.appendChild(container);
let root!: Root;
act(() => {
root = createRoot(container);
root.render(createElement(ProductSwitcher));
});
return { container, root };
}
describe('ProductSwitcher', () => {
it('renders nothing when there is zero or one product', () => {
const none = render([]);
expect(none.container.querySelector('select')).toBeNull();
act(() => none.root.unmount());
const one = render([{ id: 'solo', name: 'Solo' }]);
expect(one.container.querySelector('select')).toBeNull();
act(() => one.root.unmount());
});
it('renders a select with an option per product when there are multiple', () => {
const { container, root } = render([
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Beta' },
]);
const select = container.querySelector('select');
expect(select).not.toBeNull();
expect(select!.querySelectorAll('option')).toHaveLength(2);
expect(container.textContent).toContain('Alpha');
expect(container.textContent).toContain('Beta');
act(() => root.unmount());
});
});

View File

@ -5,6 +5,10 @@ import { useProduct } from '@/lib/product-context';
export function ProductSwitcher() {
const { productId, setProductId, products } = useProduct();
// Single-tenant deployments (a solo dev / freelancer with one project) get no
// switcher clutter — there is nothing to switch between.
if (products.length <= 1) return null;
return (
<select
value={productId}

View File

@ -10,6 +10,14 @@ import type { NextRequest } from 'next/server';
const identity = loadProductIdentity();
// Single source of truth for the configurable product list (client-safe module).
export {
KNOWN_PRODUCTS,
DEFAULT_PRODUCTS,
parseProductsEnv,
type Product,
} from './product-constants';
export const PRODUCT_ID = identity.productId;
export const DISPLAY_NAME = identity.displayName;
export const LICENSE_PREFIX = identity.licensePrefix;
@ -22,11 +30,3 @@ export const PACKAGE_NAME = identity.packageName;
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

@ -3,15 +3,62 @@
*
* Use this in 'use client' components. For server-side code that needs
* loadProductIdentity(), import from product-config.ts instead.
*
* The product list is CONFIGURABLE so this dashboard works for any deployment
* (a third-party / freelance / self-hosted operator), not just the built-in set.
* Set `NEXT_PUBLIC_PRODUCTS` to a JSON array of `{ id, name, icon? }` to override
* the default list; an empty/invalid value falls back to the built-in default.
*/
export const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || process.env.PRODUCT_ID || 'lysnrai';
export const DISPLAY_NAME = process.env.NEXT_PUBLIC_DISPLAY_NAME || 'LysnrAI';
/** All known products in the ByteLyst ecosystem. */
export const KNOWN_PRODUCTS = [
/** A selectable product/project the dashboard can be scoped to. */
export interface Product {
id: string;
name: string;
icon?: string;
}
/** Built-in default product set (the original ByteLyst ecosystem). */
export const DEFAULT_PRODUCTS: readonly Product[] = [
{ 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;
];
/**
* Parse the `NEXT_PUBLIC_PRODUCTS` override into a validated product list. PURE +
* defensive: any malformed entry (missing id/name, non-array, bad JSON) falls
* back to `fallback`, so a bad env value can never blank out the switcher.
*/
export function parseProductsEnv(
raw: string | undefined,
fallback: readonly Product[] = DEFAULT_PRODUCTS
): readonly Product[] {
if (!raw || raw.trim() === '') return fallback;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return fallback;
const products: Product[] = [];
for (const entry of parsed) {
if (!entry || typeof entry !== 'object') continue;
const { id, name, icon } = entry as Record<string, unknown>;
if (typeof id !== 'string' || id.trim() === '') continue;
products.push({
id,
name: typeof name === 'string' && name.trim() !== '' ? name : id,
...(typeof icon === 'string' ? { icon } : {}),
});
}
return products.length > 0 ? products : fallback;
} catch {
return fallback;
}
}
/** Configured product set — `NEXT_PUBLIC_PRODUCTS` override, else the default. */
export const KNOWN_PRODUCTS: readonly Product[] = parseProductsEnv(
process.env.NEXT_PUBLIC_PRODUCTS
);

View File

@ -1,7 +1,7 @@
'use client';
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-constants';
import { KNOWN_PRODUCTS, PRODUCT_ID, type Product } from '@/lib/product-constants';
const STORAGE_KEY = 'tracker_selected_product';
const PRODUCT_CHANGED_EVENT = 'tracker:product-changed';
@ -10,7 +10,7 @@ interface ProductContextValue {
productId: string;
productName: string;
setProductId: (id: string) => void;
products: typeof KNOWN_PRODUCTS;
products: readonly Product[];
}
const ProductContext = createContext<ProductContextValue | null>(null);