Packages added: - @bytelyst/referral-client — referral API client + share helpers - @bytelyst/subscription-client — subscription/plan API client + cache - @bytelyst/celebrations — milestone triggers, confetti, positive messages - @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases - @bytelyst/accessibility — VoiceOver/TalkBack label generators - @bytelyst/quick-actions — progressive disclosure, smart defaults - @bytelyst/time-references — familiar duration references - @bytelyst/org-client — org/workspace/membership/license API client - @bytelyst/marketplace-client — listing/review/install API client All packages: pure TS, ESM, globalThis.fetch, no Node.js deps. 99 Vitest tests across 9 packages, 79/79 public methods covered. Review fixes applied: - time-references: fix module-level mutable state leak + add clearCustomReferences() - accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords - subscription-client: fix flaky daysRemaining test (ms boundary race)
194 lines
5.4 KiB
TypeScript
194 lines
5.4 KiB
TypeScript
/**
|
|
* Browser/React Native-safe subscription client for platform-service.
|
|
*
|
|
* Wraps platform-service /subscriptions/* + /plans/* endpoints.
|
|
* Caches subscription and plans for offline reads.
|
|
* No Node.js dependencies — uses globalThis.fetch.
|
|
*/
|
|
|
|
import type {
|
|
PlanConfig,
|
|
SubscriptionClient,
|
|
SubscriptionClientConfig,
|
|
SubscriptionDoc,
|
|
} from './types.js';
|
|
|
|
function generateRequestId(): string {
|
|
return typeof globalThis.crypto?.randomUUID === 'function'
|
|
? globalThis.crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient {
|
|
const { baseUrl, productId, userId, getAccessToken, storage } = config;
|
|
|
|
const SUB_KEY = `${productId}-subscription`;
|
|
const PLANS_KEY = `${productId}-plans`;
|
|
|
|
let cachedSub: SubscriptionDoc | null = null;
|
|
let cachedPlans: PlanConfig[] = [];
|
|
|
|
// Restore from storage on creation
|
|
if (storage) {
|
|
try {
|
|
const raw = storage.getItem(SUB_KEY);
|
|
if (raw) cachedSub = JSON.parse(raw) as SubscriptionDoc;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
const raw = storage.getItem(PLANS_KEY);
|
|
if (raw) cachedPlans = JSON.parse(raw) as PlanConfig[];
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function headers(): Record<string, string> {
|
|
const h: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'x-product-id': productId,
|
|
'x-request-id': generateRequestId(),
|
|
};
|
|
const token = getAccessToken();
|
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
return h;
|
|
}
|
|
|
|
function persistSub(sub: SubscriptionDoc | null): void {
|
|
cachedSub = sub;
|
|
if (storage) {
|
|
try {
|
|
storage.setItem(SUB_KEY, JSON.stringify(sub));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
function persistPlans(plans: PlanConfig[]): void {
|
|
cachedPlans = plans;
|
|
if (storage) {
|
|
try {
|
|
storage.setItem(PLANS_KEY, JSON.stringify(plans));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getMySubscription(): Promise<SubscriptionDoc | null> {
|
|
const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, {
|
|
headers: headers(),
|
|
});
|
|
if (res.status === 404) {
|
|
persistSub(null);
|
|
return null;
|
|
}
|
|
if (!res.ok) throw new Error(`getMySubscription failed: ${res.status}`);
|
|
const sub = (await res.json()) as SubscriptionDoc;
|
|
persistSub(sub);
|
|
return sub;
|
|
}
|
|
|
|
async function getPlans(): Promise<PlanConfig[]> {
|
|
const res = await globalThis.fetch(`${baseUrl}/plans`, { headers: headers() });
|
|
if (!res.ok) throw new Error(`getPlans failed: ${res.status}`);
|
|
const data = (await res.json()) as { plans: PlanConfig[] };
|
|
const plans = data.plans;
|
|
persistPlans(plans);
|
|
return plans;
|
|
}
|
|
|
|
async function startTrial(planName = 'pro'): Promise<SubscriptionDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/subscriptions`, {
|
|
method: 'POST',
|
|
headers: headers(),
|
|
body: JSON.stringify({ userId, productId, plan: planName, status: 'trialing' }),
|
|
});
|
|
if (!res.ok) throw new Error(`startTrial failed: ${res.status}`);
|
|
const sub = (await res.json()) as SubscriptionDoc;
|
|
persistSub(sub);
|
|
return sub;
|
|
}
|
|
|
|
async function cancelSubscription(): Promise<SubscriptionDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, {
|
|
method: 'PUT',
|
|
headers: headers(),
|
|
body: JSON.stringify({ cancelAtPeriodEnd: true }),
|
|
});
|
|
if (!res.ok) throw new Error(`cancelSubscription failed: ${res.status}`);
|
|
const sub = (await res.json()) as SubscriptionDoc;
|
|
persistSub(sub);
|
|
return sub;
|
|
}
|
|
|
|
async function updateSubscription(updates: Partial<SubscriptionDoc>): Promise<SubscriptionDoc> {
|
|
const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, {
|
|
method: 'PUT',
|
|
headers: headers(),
|
|
body: JSON.stringify(updates),
|
|
});
|
|
if (!res.ok) throw new Error(`updateSubscription failed: ${res.status}`);
|
|
const sub = (await res.json()) as SubscriptionDoc;
|
|
persistSub(sub);
|
|
return sub;
|
|
}
|
|
|
|
function isPro(): boolean {
|
|
if (!cachedSub) return false;
|
|
return (
|
|
cachedSub.plan !== 'free' &&
|
|
(cachedSub.status === 'active' || cachedSub.status === 'trialing')
|
|
);
|
|
}
|
|
|
|
function isTrialing(): boolean {
|
|
return cachedSub?.status === 'trialing' || false;
|
|
}
|
|
|
|
function hasFeature(feature: string): boolean {
|
|
if (!cachedSub) return false;
|
|
const plan = cachedPlans.find(p => p.name === cachedSub!.plan);
|
|
if (!plan) return false;
|
|
return plan.features.includes(feature);
|
|
}
|
|
|
|
function daysRemaining(): number | null {
|
|
if (!cachedSub) return null;
|
|
const end = new Date(cachedSub.currentPeriodEnd).getTime();
|
|
const now = Date.now();
|
|
const diff = end - now;
|
|
if (diff <= 0) return 0;
|
|
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function getCachedSubscription(): SubscriptionDoc | null {
|
|
return cachedSub;
|
|
}
|
|
|
|
function getCachedPlans(): PlanConfig[] {
|
|
return cachedPlans;
|
|
}
|
|
|
|
async function refresh(): Promise<void> {
|
|
await Promise.all([getMySubscription(), getPlans()]);
|
|
}
|
|
|
|
return {
|
|
getMySubscription,
|
|
getPlans,
|
|
startTrial,
|
|
cancelSubscription,
|
|
updateSubscription,
|
|
isPro,
|
|
isTrialing,
|
|
hasFeature,
|
|
daysRemaining,
|
|
getCachedSubscription,
|
|
getCachedPlans,
|
|
refresh,
|
|
};
|
|
}
|