diff --git a/web/package-lock.json b/web/package-lock.json index 50fdc0f..c587e2c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,9 @@ "@bytelyst/api-client": "file:../../learning_ai_common_plat/packages/api-client", "@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client", "@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-client", + "@bytelyst/feature-flag-client": "file:../../learning_ai_common_plat/packages/feature-flag-client", "@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth", + "@bytelyst/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client", "@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client", "@serwist/next": "^9.5.6", "date-fns": "^4.1.0", @@ -66,6 +68,10 @@ "zod": "^3.22.0" } }, + "../../learning_ai_common_plat/packages/feature-flag-client": { + "name": "@bytelyst/feature-flag-client", + "version": "0.1.0" + }, "../../learning_ai_common_plat/packages/react-auth": { "name": "@bytelyst/react-auth", "version": "0.1.0", @@ -84,6 +90,10 @@ "react": ">=18.0.0" } }, + "../../learning_ai_common_plat/packages/subscription-client": { + "name": "@bytelyst/subscription-client", + "version": "0.1.0" + }, "../../learning_ai_common_plat/packages/telemetry-client": { "name": "@bytelyst/telemetry-client", "version": "0.1.0" @@ -449,10 +459,18 @@ "resolved": "../../learning_ai_common_plat/packages/diagnostics-client", "link": true }, + "node_modules/@bytelyst/feature-flag-client": { + "resolved": "../../learning_ai_common_plat/packages/feature-flag-client", + "link": true + }, "node_modules/@bytelyst/react-auth": { "resolved": "../../learning_ai_common_plat/packages/react-auth", "link": true }, + "node_modules/@bytelyst/subscription-client": { + "resolved": "../../learning_ai_common_plat/packages/subscription-client", + "link": true + }, "node_modules/@bytelyst/telemetry-client": { "resolved": "../../learning_ai_common_plat/packages/telemetry-client", "link": true diff --git a/web/package.json b/web/package.json index 5705f13..0b40f0c 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,8 @@ "@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client", "@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth", "@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-client", + "@bytelyst/feature-flag-client": "file:../../learning_ai_common_plat/packages/feature-flag-client", + "@bytelyst/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client", "@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client", "@serwist/next": "^9.5.6", "date-fns": "^4.1.0", diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 4fc46cb..610978b 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -13,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) { useEffect(() => { initTelemetry(); - initFeatureFlags({ platform: 'web' }); + initFeatureFlags(); initDiagnostics(); }, []); diff --git a/web/src/lib/billing-client.ts b/web/src/lib/billing-client.ts index 6e69e7f..bb4b26c 100644 --- a/web/src/lib/billing-client.ts +++ b/web/src/lib/billing-client.ts @@ -1,33 +1,32 @@ /** - * Billing / Subscription client stub for ChronoMind. + * Billing / Subscription client — thin wrapper over @bytelyst/subscription-client. * - * Calls platform-service subscription + usage endpoints via the auth-api base URL. + * Delegates subscription operations to the shared package. + * Keeps ChronoMind-specific usage API as a local fetch call. * Client-side only — uses the stored auth token for authorization. */ -import { PRODUCT_ID, getBaseUrl } from './auth-api'; +import { createSubscriptionClient } from '@bytelyst/subscription-client'; +import type { SubscriptionDoc } from '@bytelyst/subscription-client'; +import { PRODUCT_ID, getBaseUrl, getAuthClient } from './auth-api'; -function getToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem('chronomind_access_token'); +// ── Shared client ────────────────────────────────────────────────── + +let _client: ReturnType | null = null; + +function getClient() { + if (!_client) { + _client = createSubscriptionClient({ + baseUrl: getBaseUrl(), + productId: PRODUCT_ID, + userId: 'me', + getAccessToken: () => getAuthClient().getAccessToken() ?? '', + }); + } + return _client; } -async function billingFetch(path: string, options?: RequestInit): Promise { - const token = getToken(); - const res = await fetch(`${getBaseUrl()}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - 'x-product-id': PRODUCT_ID, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(options?.headers as Record), - }, - }); - if (!res.ok) throw new Error(`Billing API error: ${res.status}`); - return res.json(); -} - -// ── Types ────────────────────────────────────────────────────────── +// ── Types (backwards-compatible) ─────────────────────────────────── export interface Subscription { id: string; @@ -44,28 +43,44 @@ export interface Subscription { updatedAt: string; } +function toSubscription(doc: SubscriptionDoc): Subscription { + return { + id: doc.id, + productId: doc.productId ?? PRODUCT_ID, + userId: doc.userId, + plan: doc.plan as Subscription['plan'], + status: doc.status as Subscription['status'], + currentPeriodStart: doc.currentPeriodStart, + currentPeriodEnd: doc.currentPeriodEnd, + cancelAtPeriodEnd: doc.cancelAtPeriodEnd, + stripeCustomerId: doc.stripeCustomerId, + stripeSubscriptionId: doc.stripeSubscriptionId, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +} + // ── Subscription API ─────────────────────────────────────────────── -export async function getMySubscription(userId: string): Promise { +export async function getMySubscription(_userId?: string): Promise { try { - return await billingFetch(`/subscriptions/${userId}`); + const doc = await getClient().getMySubscription(); + return doc ? toSubscription(doc) : null; } catch { return null; } } -export async function cancelSubscription(userId: string): Promise { +export async function cancelSubscription(_userId?: string): Promise { try { - return await billingFetch(`/subscriptions/${userId}`, { - method: 'PUT', - body: JSON.stringify({ cancelAtPeriodEnd: true }), - }); + const doc = await getClient().cancelSubscription(); + return toSubscription(doc); } catch { return null; } } -// ── Usage API ────────────────────────────────────────────────────── +// ── Usage API (ChronoMind-specific, not in shared client) ────────── export interface UsageSummary { timersCreated: number; @@ -75,8 +90,17 @@ export interface UsageSummary { export async function getUsageSummary(userId: string, days = 30): Promise { try { - const data = await billingFetch<{ summary: UsageSummary }>(`/usage/summary?userId=${userId}&days=${days}`); - return data.summary as UsageSummary; + const token = getAuthClient().getAccessToken(); + const res = await fetch(`${getBaseUrl()}/usage/summary?userId=${userId}&days=${days}`, { + headers: { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!res.ok) return null; + const data = await res.json() as { summary: UsageSummary }; + return data.summary; } catch { return null; } diff --git a/web/src/lib/feature-flags.ts b/web/src/lib/feature-flags.ts index 7294d11..65ce06f 100644 --- a/web/src/lib/feature-flags.ts +++ b/web/src/lib/feature-flags.ts @@ -1,70 +1,41 @@ /** - * Feature flag client — polls platform-service /flags/poll endpoint. + * Feature flag client — thin wrapper over @bytelyst/feature-flag-client. * - * Flags are fetched on init and cached in memory. Re-polls on a configurable - * interval (default 5 min). Consumers call `isEnabled('flag_key')`. + * Delegates polling and caching to the shared package. + * Preserves the same public API for existing consumers. * * Privacy: sends userId + platform only. No PII. */ -import { PRODUCT_ID } from './auth-api'; +import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; +import { PRODUCT_ID, getBaseUrl } from './auth-api'; -const PLATFORM_URL = process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; -const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes - -let _flags: Record = {}; -let _initialized = false; -let _intervalId: ReturnType | null = null; - -interface PollParams { - userId?: string; - platform?: string; -} - -async function fetchFlags(params: PollParams): Promise> { - try { - const qs = new URLSearchParams(); - if (params.userId) qs.set('userId', params.userId); - if (params.platform) qs.set('platform', params.platform); - const res = await fetch(`${PLATFORM_URL}/api/flags/poll?${qs.toString()}`, { - headers: { 'x-product-id': PRODUCT_ID }, - }); - if (!res.ok) return _flags; - const data = await res.json(); - return (data as { flags: Record }).flags ?? {}; - } catch { - return _flags; - } -} +const client = createFeatureFlagClient({ + baseUrl: getBaseUrl(), + productId: PRODUCT_ID, + platform: 'web', + pollIntervalMs: 5 * 60 * 1000, +}); /** * Initialize the flag client. Call once on app startup. * Fetches flags immediately and starts polling. */ -export async function initFeatureFlags(params: PollParams = {}): Promise { - if (_initialized) return; - _initialized = true; - const pollParams: PollParams = { platform: 'web', ...params }; - _flags = await fetchFlags(pollParams); - _intervalId = setInterval(async () => { - _flags = await fetchFlags(pollParams); - }, POLL_INTERVAL_MS); +export async function initFeatureFlags(params: { userId?: string } = {}): Promise { + await client.init(params); } /** Check if a feature flag is enabled. Returns false if not found or not initialized. */ export function isEnabled(key: string): boolean { - return _flags[key] === true; + return client.isEnabled(key); } /** Get all currently cached flags. */ export function getAllFlags(): Readonly> { - return _flags; + return client.getAllFlags(); } /** Stop polling and reset state. Useful for tests. */ export function resetFeatureFlags(): void { - if (_intervalId) clearInterval(_intervalId); - _intervalId = null; - _flags = {}; - _initialized = false; + client.stop(); }