feat(admin): add product switcher — filter all data by any product

- product-config.ts: add getRequestProductId(req) helper + KNOWN_PRODUCTS list
- product-context.tsx: new React context storing selected productId in localStorage
- product-switcher.tsx: dropdown component with icons for all 4 products
- api.ts: pass x-product-id header from localStorage on all API calls
- providers.tsx: wrap with ProductProvider
- sidebar-nav.tsx: render ProductSwitcher between logo and nav
- repositories/users.ts + tokens.ts: accept optional productId parameter
- 8 API routes updated: users, auth/login, auth/forgot-password, seed,
  settings/plans, themes/active, analytics/retention, analytics/revenue
This commit is contained in:
saravanakumardb1 2026-02-28 14:12:15 -08:00
parent ba2641c552
commit ed7fa3f9a4
7 changed files with 110 additions and 22 deletions

View File

@ -39,6 +39,7 @@ export async function GET(req: NextRequest) {
const url = new URL(req.url);
const months = parseInt(url.searchParams.get('months') ?? '6', 10);
const productId = getRequestProductId(req);
const now = new Date();
const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString();
@ -62,7 +63,7 @@ export async function GET(req: NextRequest) {
"WHERE c.productId = @pid AND c.status = 'canceled' " +
'AND c.canceledAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
@ -74,7 +75,7 @@ export async function GET(req: NextRequest) {
query:
'SELECT c.id, c.createdAt FROM c ' + 'WHERE c.productId = @pid AND c.createdAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
@ -89,7 +90,7 @@ export async function GET(req: NextRequest) {
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
'AND c.paidAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})

View File

@ -11,7 +11,7 @@ import { logError } from '@/lib/logger';
import { initializeAllContainers } from '@/lib/cosmos';
import { hashPassword } from '@/lib/auth-server';
import { getUserByEmail, createUser } from '@/lib/repositories/users';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
export async function POST(req: NextRequest) {
try {
const seedSecret = process.env.SEED_SECRET;
@ -25,13 +25,14 @@ export async function POST(req: NextRequest) {
// 1. Create all Cosmos DB containers
await initializeAllContainers();
// 2. Create default admin user if not exists
const productId = getRequestProductId(req);
const adminEmail = 'admin@example.com';
const existing = await getUserByEmail(adminEmail);
const existing = await getUserByEmail(adminEmail, productId);
if (!existing) {
const now = new Date().toISOString();
await createUser({
id: `usr_admin_${Date.now()}`,
productId: PRODUCT_ID,
productId,
email: adminEmail,
name: 'Admin User',
passwordHash: await hashPassword('admin123'),
@ -47,12 +48,12 @@ export async function POST(req: NextRequest) {
}
// 3. Create viewer user if not exists
const viewerEmail = 'viewer@example.com';
const existingViewer = await getUserByEmail(viewerEmail);
const existingViewer = await getUserByEmail(viewerEmail, productId);
if (!existingViewer) {
const now = new Date().toISOString();
await createUser({
id: `usr_viewer_${Date.now()}`,
productId: PRODUCT_ID,
productId,
email: viewerEmail,
name: 'Viewer User',
passwordHash: await hashPassword('viewer123'),

View File

@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { listPlans } from '@/lib/platform-client';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
import { logError } from '@/lib/logger';
export async function GET(req: NextRequest) {
@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const result = await listPlans(PRODUCT_ID);
const result = await listPlans(getRequestProductId(req));
return NextResponse.json(result);
} catch (error) {
logError('Plans list error', error);

View File

@ -1,11 +1,11 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getActiveTheme } from '@/lib/platform-client';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
export async function GET() {
export async function GET(req: NextRequest) {
try {
const theme = await getActiveTheme(PRODUCT_ID);
const theme = await getActiveTheme(getRequestProductId(req));
return NextResponse.json(theme);
} catch (error) {
logError('Failed to get active theme', error);
@ -14,9 +14,40 @@ export async function GET() {
id: 'default',
name: 'Default Green',
description: 'Default green theme',
ios: { primary: '#4caf50', secondary: '#2e7d32', accent: '#66bb6a', background: '#ffffff', surface: '#f5f5f5', error: '#f44336', warning: '#ff9800', success: '#4caf50' },
android: { primary: '#4caf50', secondary: '#2e7d32', accent: '#66bb6a', background: '#ffffff', surface: '#f5f5f5', error: '#f44336', warning: '#ff9800', success: '#4caf50' },
desktop: { primary: '#4caf50', secondary: '#2e7d32', accent: '#66bb6a', background: '#ffffff', surface: '#f5f5f5', error: '#f44336', warning: '#ff9800', success: '#4caf50', idle: '#4caf50', listening: '#e94560', processing: '#f5a623', offline: '#9e9e9e' },
ios: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
},
android: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
},
desktop: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
idle: '#4caf50',
listening: '#e94560',
processing: '#f5a623',
offline: '#9e9e9e',
},
is_active: true,
is_default: true,
version: '1.0',

View File

@ -4,6 +4,7 @@ import { useEffect, type ReactNode } from 'react';
import { AuthProvider } from '@/lib/auth-context';
import { ThemeProvider } from '@/lib/theme-context';
import { StripeConfigProvider } from '@/lib/stripe-context';
import { ProductProvider } from '@/lib/product-context';
import { ToastProvider } from '@/components/ui/toast';
import { CSPostHogProvider } from '@/components/posthog-provider';
import { initTelemetry } from '@/lib/telemetry';
@ -16,11 +17,13 @@ export function Providers({ children }: { children: ReactNode }) {
return (
<CSPostHogProvider>
<ThemeProvider>
<AuthProvider>
<StripeConfigProvider>
<ToastProvider>{children}</ToastProvider>
</StripeConfigProvider>
</AuthProvider>
<ProductProvider>
<AuthProvider>
<StripeConfigProvider>
<ToastProvider>{children}</ToastProvider>
</StripeConfigProvider>
</AuthProvider>
</ProductProvider>
</ThemeProvider>
</CSPostHogProvider>
);

View File

@ -0,0 +1,48 @@
'use client';
import { useProduct } from '@/lib/product-context';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Mic, Clock, Apple, Brain, type LucideIcon } from 'lucide-react';
const iconMap: Record<string, LucideIcon> = {
Mic,
Clock,
Apple,
Brain,
};
export function ProductSwitcher() {
const { productId, setProductId, products } = useProduct();
return (
<div className="px-3 py-2">
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-3 mb-1 block">
Product
</label>
<Select value={productId} onValueChange={setProductId}>
<SelectTrigger className="w-full h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{products.map(p => {
const Icon = iconMap[p.icon];
return (
<SelectItem key={p.id} value={p.id}>
<span className="flex items-center gap-2">
{Icon && <Icon className="h-3.5 w-3.5" />}
{p.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}

View File

@ -32,6 +32,7 @@ import {
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth-context';
import { useTheme } from '@/lib/theme-context';
import { ProductSwitcher } from '@/components/product-switcher';
const navItems = [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
@ -100,6 +101,9 @@ export function SidebarNav() {
</button>
</div>
{/* Product Switcher */}
<ProductSwitcher />
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{navItems.map(item => {