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:
parent
ba2641c552
commit
ed7fa3f9a4
@ -39,6 +39,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const months = parseInt(url.searchParams.get('months') ?? '6', 10);
|
const months = parseInt(url.searchParams.get('months') ?? '6', 10);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString();
|
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' " +
|
"WHERE c.productId = @pid AND c.status = 'canceled' " +
|
||||||
'AND c.canceledAt >= @since',
|
'AND c.canceledAt >= @since',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@pid', value: PRODUCT_ID },
|
{ name: '@pid', value: productId },
|
||||||
{ name: '@since', value: sinceDate },
|
{ name: '@since', value: sinceDate },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@ -74,7 +75,7 @@ export async function GET(req: NextRequest) {
|
|||||||
query:
|
query:
|
||||||
'SELECT c.id, c.createdAt FROM c ' + 'WHERE c.productId = @pid AND c.createdAt >= @since',
|
'SELECT c.id, c.createdAt FROM c ' + 'WHERE c.productId = @pid AND c.createdAt >= @since',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@pid', value: PRODUCT_ID },
|
{ name: '@pid', value: productId },
|
||||||
{ name: '@since', value: sinceDate },
|
{ name: '@since', value: sinceDate },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@ -89,7 +90,7 @@ export async function GET(req: NextRequest) {
|
|||||||
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
|
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
|
||||||
'AND c.paidAt >= @since',
|
'AND c.paidAt >= @since',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@pid', value: PRODUCT_ID },
|
{ name: '@pid', value: productId },
|
||||||
{ name: '@since', value: sinceDate },
|
{ name: '@since', value: sinceDate },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { logError } from '@/lib/logger';
|
|||||||
import { initializeAllContainers } from '@/lib/cosmos';
|
import { initializeAllContainers } from '@/lib/cosmos';
|
||||||
import { hashPassword } from '@/lib/auth-server';
|
import { hashPassword } from '@/lib/auth-server';
|
||||||
import { getUserByEmail, createUser } from '@/lib/repositories/users';
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const seedSecret = process.env.SEED_SECRET;
|
const seedSecret = process.env.SEED_SECRET;
|
||||||
@ -25,13 +25,14 @@ export async function POST(req: NextRequest) {
|
|||||||
// 1. Create all Cosmos DB containers
|
// 1. Create all Cosmos DB containers
|
||||||
await initializeAllContainers();
|
await initializeAllContainers();
|
||||||
// 2. Create default admin user if not exists
|
// 2. Create default admin user if not exists
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
const adminEmail = 'admin@example.com';
|
const adminEmail = 'admin@example.com';
|
||||||
const existing = await getUserByEmail(adminEmail);
|
const existing = await getUserByEmail(adminEmail, productId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await createUser({
|
await createUser({
|
||||||
id: `usr_admin_${Date.now()}`,
|
id: `usr_admin_${Date.now()}`,
|
||||||
productId: PRODUCT_ID,
|
productId,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
name: 'Admin User',
|
name: 'Admin User',
|
||||||
passwordHash: await hashPassword('admin123'),
|
passwordHash: await hashPassword('admin123'),
|
||||||
@ -47,12 +48,12 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
// 3. Create viewer user if not exists
|
// 3. Create viewer user if not exists
|
||||||
const viewerEmail = 'viewer@example.com';
|
const viewerEmail = 'viewer@example.com';
|
||||||
const existingViewer = await getUserByEmail(viewerEmail);
|
const existingViewer = await getUserByEmail(viewerEmail, productId);
|
||||||
if (!existingViewer) {
|
if (!existingViewer) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await createUser({
|
await createUser({
|
||||||
id: `usr_viewer_${Date.now()}`,
|
id: `usr_viewer_${Date.now()}`,
|
||||||
productId: PRODUCT_ID,
|
productId,
|
||||||
email: viewerEmail,
|
email: viewerEmail,
|
||||||
name: 'Viewer User',
|
name: 'Viewer User',
|
||||||
passwordHash: await hashPassword('viewer123'),
|
passwordHash: await hashPassword('viewer123'),
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAdmin } from '@/lib/auth-server';
|
import { requireAdmin } from '@/lib/auth-server';
|
||||||
import { listPlans } from '@/lib/platform-client';
|
import { listPlans } from '@/lib/platform-client';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const admin = await requireAdmin(req);
|
const admin = await requireAdmin(req);
|
||||||
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
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);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Plans list error', error);
|
logError('Plans list error', error);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { getActiveTheme } from '@/lib/platform-client';
|
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 {
|
try {
|
||||||
const theme = await getActiveTheme(PRODUCT_ID);
|
const theme = await getActiveTheme(getRequestProductId(req));
|
||||||
return NextResponse.json(theme);
|
return NextResponse.json(theme);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to get active theme', error);
|
logError('Failed to get active theme', error);
|
||||||
@ -14,9 +14,40 @@ export async function GET() {
|
|||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'Default Green',
|
name: 'Default Green',
|
||||||
description: 'Default green theme',
|
description: 'Default green theme',
|
||||||
ios: { primary: '#4caf50', secondary: '#2e7d32', accent: '#66bb6a', background: '#ffffff', surface: '#f5f5f5', error: '#f44336', warning: '#ff9800', success: '#4caf50' },
|
ios: {
|
||||||
android: { primary: '#4caf50', secondary: '#2e7d32', accent: '#66bb6a', background: '#ffffff', surface: '#f5f5f5', error: '#f44336', warning: '#ff9800', success: '#4caf50' },
|
primary: '#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' },
|
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_active: true,
|
||||||
is_default: true,
|
is_default: true,
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 { StripeConfigProvider } from '@/lib/stripe-context';
|
import { StripeConfigProvider } from '@/lib/stripe-context';
|
||||||
|
import { ProductProvider } from '@/lib/product-context';
|
||||||
import { ToastProvider } from '@/components/ui/toast';
|
import { ToastProvider } from '@/components/ui/toast';
|
||||||
import { CSPostHogProvider } from '@/components/posthog-provider';
|
import { CSPostHogProvider } from '@/components/posthog-provider';
|
||||||
import { initTelemetry } from '@/lib/telemetry';
|
import { initTelemetry } from '@/lib/telemetry';
|
||||||
@ -16,11 +17,13 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<CSPostHogProvider>
|
<CSPostHogProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<ProductProvider>
|
||||||
<StripeConfigProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<StripeConfigProvider>
|
||||||
</StripeConfigProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</AuthProvider>
|
</StripeConfigProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ProductProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</CSPostHogProvider>
|
</CSPostHogProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
48
dashboards/admin-web/src/components/product-switcher.tsx
Normal file
48
dashboards/admin-web/src/components/product-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
|
import { ProductSwitcher } from '@/components/product-switcher';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
@ -100,6 +101,9 @@ export function SidebarNav() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Product Switcher */}
|
||||||
|
<ProductSwitcher />
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user