- 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
162 lines
4.9 KiB
TypeScript
162 lines
4.9 KiB
TypeScript
/**
|
|
* Client-side API helper for the Tracker Service.
|
|
* Uses @bytelyst/api-client shared package.
|
|
* All calls go through Next.js API routes to keep tokens server-side.
|
|
*/
|
|
|
|
import { createApiClient } from '@bytelyst/api-client';
|
|
|
|
export interface TrackerItem {
|
|
id: string;
|
|
productId: string;
|
|
type: 'bug' | 'feature' | 'task';
|
|
status: 'open' | 'in_progress' | 'done' | 'closed' | 'wont_fix';
|
|
priority: 'critical' | 'high' | 'medium' | 'low';
|
|
title: string;
|
|
description: string;
|
|
labels: string[];
|
|
assignee: string | null;
|
|
reportedBy: string;
|
|
source: 'internal' | 'user_submitted' | 'auto_detected';
|
|
visibility: 'internal' | 'public';
|
|
voteCount: number;
|
|
commentCount: number;
|
|
targetRelease: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface Comment {
|
|
id: string;
|
|
itemId: string;
|
|
productId: string;
|
|
authorId: string;
|
|
authorEmail: string | null;
|
|
body: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface TrackerStats {
|
|
productId: string;
|
|
total: number;
|
|
byType: Record<string, number>;
|
|
byStatus: Record<string, number>;
|
|
byPriority: Record<string, number>;
|
|
}
|
|
|
|
export interface ListItemsResponse {
|
|
items: TrackerItem[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
const trackerApi = createApiClient({
|
|
baseUrl: '/api/tracker',
|
|
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null),
|
|
});
|
|
|
|
/** 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()}` : '';
|
|
return apiFetch(`/items${qs}`);
|
|
}
|
|
|
|
export async function getItem(id: string): Promise<TrackerItem> {
|
|
return apiFetch(`/items/${id}`);
|
|
}
|
|
|
|
export async function createItem(data: Partial<TrackerItem>): Promise<TrackerItem> {
|
|
return apiFetch('/items', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function updateItem(id: string, data: Partial<TrackerItem>): Promise<TrackerItem> {
|
|
return apiFetch(`/items/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function updateItemStatus(id: string, status: string): Promise<TrackerItem> {
|
|
return apiFetch(`/items/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }) });
|
|
}
|
|
|
|
export async function deleteItem(id: string): Promise<void> {
|
|
await apiFetch(`/items/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function getStats(productId?: string): Promise<TrackerStats> {
|
|
const qs = productId ? `?productId=${productId}` : '';
|
|
return apiFetch(`/items/stats${qs}`);
|
|
}
|
|
|
|
export async function listComments(
|
|
itemId: string
|
|
): Promise<{ comments: Comment[]; count: number }> {
|
|
return apiFetch(`/items/${itemId}/comments`);
|
|
}
|
|
|
|
export async function addComment(itemId: string, body: string): Promise<Comment> {
|
|
return apiFetch(`/items/${itemId}/comments`, { method: 'POST', body: JSON.stringify({ body }) });
|
|
}
|
|
|
|
export async function toggleVote(itemId: string): Promise<{ voted: boolean; voteCount: number }> {
|
|
return apiFetch(`/items/${itemId}/vote`, { method: 'POST' });
|
|
}
|
|
|
|
// ── Public Roadmap API (no auth required) ────────────────────────────
|
|
|
|
const publicApi = createApiClient({ baseUrl: '/api/tracker' });
|
|
const publicFetch = publicApi.fetch;
|
|
|
|
export interface PublicRoadmapStats {
|
|
total: number;
|
|
byStatus: Record<string, number>;
|
|
byType: Record<string, number>;
|
|
totalVotes: number;
|
|
}
|
|
|
|
export async function getRoadmapItems(params?: Record<string, string>): Promise<ListItemsResponse> {
|
|
const qs = params ? `?${new URLSearchParams(params).toString()}` : '';
|
|
return publicFetch(`/public/roadmap${qs}`);
|
|
}
|
|
|
|
export async function getRoadmapStats(productId?: string): Promise<PublicRoadmapStats> {
|
|
const qs = productId ? `?productId=${productId}` : '';
|
|
return publicFetch(`/public/roadmap/stats${qs}`);
|
|
}
|
|
|
|
export async function getPublicItem(id: string): Promise<TrackerItem> {
|
|
return publicFetch(`/public/items/${id}`);
|
|
}
|
|
|
|
export async function submitPublicItem(data: {
|
|
type?: string;
|
|
title: string;
|
|
description?: string;
|
|
email: string;
|
|
name: string;
|
|
}): Promise<{ id: string; title: string; status: string }> {
|
|
return publicFetch('/public/submit', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
|
|
export async function publicVote(
|
|
itemId: string,
|
|
email: string
|
|
): Promise<{ voted: boolean; voteCount: number }> {
|
|
return publicFetch(`/public/items/${itemId}/vote`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
}
|