feat(shared-packages): add 9 @bytelyst/* client packages with 100% API coverage
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)
This commit is contained in:
parent
c87a8e9ef1
commit
be03efa111
21
packages/accessibility/package.json
Normal file
21
packages/accessibility/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/accessibility",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "VoiceOver/TalkBack accessibility label generators for common UI patterns",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
154
packages/accessibility/src/client.test.ts
Normal file
154
packages/accessibility/src/client.test.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buttonLabel,
|
||||
timerLabel,
|
||||
progressLabel,
|
||||
sliderLabel,
|
||||
alertLabel,
|
||||
achievementLabel,
|
||||
streakLabel,
|
||||
listItemLabel,
|
||||
formatDurationForA11y,
|
||||
formatNumberForA11y,
|
||||
buildAnnouncement,
|
||||
getPositiveBreakMessage,
|
||||
} from './client.js';
|
||||
|
||||
describe('accessibility label generators', () => {
|
||||
it('buttonLabel returns correct props', () => {
|
||||
const props = buttonLabel('Start Timer', 'Begins a new timer');
|
||||
expect(props.accessible).toBe(true);
|
||||
expect(props.accessibilityLabel).toBe('Start Timer');
|
||||
expect(props.accessibilityHint).toBe('Begins a new timer');
|
||||
expect(props.accessibilityRole).toBe('button');
|
||||
});
|
||||
|
||||
it('timerLabel with context', () => {
|
||||
const props = timerLabel('running', '2 hours 30 minutes', 'Intermittent fasting');
|
||||
expect(props.accessibilityLabel).toContain('running');
|
||||
expect(props.accessibilityLabel).toContain('2 hours 30 minutes');
|
||||
expect(props.accessibilityLabel).toContain('Intermittent fasting');
|
||||
expect(props.accessibilityRole).toBe('timer');
|
||||
});
|
||||
|
||||
it('timerLabel without context', () => {
|
||||
const props = timerLabel('paused', '10 minutes');
|
||||
expect(props.accessibilityLabel).toBe('Timer paused, 10 minutes');
|
||||
});
|
||||
|
||||
it('progressLabel with description', () => {
|
||||
const props = progressLabel('Fasting', 75, 'On track');
|
||||
expect(props.accessibilityLabel).toContain('75 percent');
|
||||
expect(props.accessibilityLabel).toContain('On track');
|
||||
expect(props.accessibilityRole).toBe('progressbar');
|
||||
expect(props.accessibilityValue?.now).toBe(75);
|
||||
});
|
||||
|
||||
it('progressLabel clamps to 0-100', () => {
|
||||
const neg = progressLabel('Test', -10);
|
||||
expect(neg.accessibilityValue?.now).toBe(0);
|
||||
|
||||
const over = progressLabel('Test', 150);
|
||||
expect(over.accessibilityValue?.now).toBe(100);
|
||||
});
|
||||
|
||||
it('sliderLabel with max', () => {
|
||||
const props = sliderLabel('Volume', 7, 10);
|
||||
expect(props.accessibilityLabel).toBe('Volume: 7 of 10');
|
||||
expect(props.accessibilityRole).toBe('adjustable');
|
||||
});
|
||||
|
||||
it('sliderLabel without max', () => {
|
||||
const props = sliderLabel('Score', 42);
|
||||
expect(props.accessibilityLabel).toBe('Score: 42');
|
||||
});
|
||||
|
||||
it('alertLabel', () => {
|
||||
const props = alertLabel('Warning', 'High heart rate detected');
|
||||
expect(props.accessibilityLabel).toBe('Warning alert: High heart rate detected');
|
||||
expect(props.accessibilityRole).toBe('alert');
|
||||
});
|
||||
|
||||
it('achievementLabel earned', () => {
|
||||
const props = achievementLabel('Early Bird', 'Complete 5 morning sessions', true);
|
||||
expect(props.accessibilityLabel).toContain('Earned');
|
||||
expect(props.accessibilityLabel).toContain('Early Bird');
|
||||
expect(props.accessibilityState?.selected).toBe(true);
|
||||
});
|
||||
|
||||
it('achievementLabel locked', () => {
|
||||
const props = achievementLabel('Night Owl', 'Complete 5 late sessions', false);
|
||||
expect(props.accessibilityLabel).toContain('Locked');
|
||||
expect(props.accessibilityState?.selected).toBe(false);
|
||||
});
|
||||
|
||||
it('streakLabel', () => {
|
||||
const props = streakLabel(7, 14);
|
||||
expect(props.accessibilityLabel).toContain('7 days');
|
||||
expect(props.accessibilityLabel).toContain('14 days');
|
||||
});
|
||||
|
||||
it('listItemLabel with badge', () => {
|
||||
const props = listItemLabel('16:8 Protocol', '16 hours fasting', 'Popular');
|
||||
expect(props.accessibilityLabel).toBe('16:8 Protocol, 16 hours fasting, Popular');
|
||||
});
|
||||
|
||||
it('listItemLabel minimal', () => {
|
||||
const props = listItemLabel('Simple Item');
|
||||
expect(props.accessibilityLabel).toBe('Simple Item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationForA11y', () => {
|
||||
it('formats hours and minutes', () => {
|
||||
const result = formatDurationForA11y(16.5 * 60 * 60 * 1000);
|
||||
expect(result).toBe('16 hours 30 minutes');
|
||||
});
|
||||
|
||||
it('formats zero', () => {
|
||||
expect(formatDurationForA11y(0)).toBe('0 seconds');
|
||||
});
|
||||
|
||||
it('formats singular units', () => {
|
||||
const result = formatDurationForA11y(3661000); // 1h 1m 1s
|
||||
expect(result).toBe('1 hour 1 minute 1 second');
|
||||
});
|
||||
|
||||
it('handles negative as zero', () => {
|
||||
expect(formatDurationForA11y(-1000)).toBe('0 seconds');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNumberForA11y', () => {
|
||||
it('formats small numbers', () => {
|
||||
expect(formatNumberForA11y(5)).toBe('five');
|
||||
expect(formatNumberForA11y(0)).toBe('zero');
|
||||
expect(formatNumberForA11y(13)).toBe('thirteen');
|
||||
});
|
||||
|
||||
it('formats larger numbers', () => {
|
||||
expect(formatNumberForA11y(42)).toBe('forty two');
|
||||
expect(formatNumberForA11y(100)).toBe('one hundred');
|
||||
expect(formatNumberForA11y(1234)).toBe('one thousand two hundred thirty four');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAnnouncement', () => {
|
||||
it('combines headline and detail', () => {
|
||||
const result = buildAnnouncement('Fast Complete', 'You fasted for 16 hours');
|
||||
expect(result).toBe('Fast Complete. You fasted for 16 hours');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPositiveBreakMessage', () => {
|
||||
it('returns a positive message', () => {
|
||||
const msg = getPositiveBreakMessage(50);
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clamps to valid range', () => {
|
||||
const msgNeg = getPositiveBreakMessage(-10);
|
||||
const msg0 = getPositiveBreakMessage(0);
|
||||
expect(msgNeg).toBe(msg0);
|
||||
});
|
||||
});
|
||||
172
packages/accessibility/src/client.ts
Normal file
172
packages/accessibility/src/client.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* VoiceOver/TalkBack accessibility label generators for common UI patterns.
|
||||
*
|
||||
* Returns A11yProps objects compatible with React Native accessibilityLabel/Role.
|
||||
* Web apps can map to aria-label / role.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type { A11yProps } from './types.js';
|
||||
|
||||
export function buttonLabel(label: string, hint?: string): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityHint: hint,
|
||||
accessibilityRole: 'button',
|
||||
};
|
||||
}
|
||||
|
||||
export function timerLabel(status: string, elapsedText: string, context?: string): A11yProps {
|
||||
const parts = [`Timer ${status}`, elapsedText];
|
||||
if (context) parts.push(context);
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: parts.join(', '),
|
||||
accessibilityRole: 'timer',
|
||||
};
|
||||
}
|
||||
|
||||
export function progressLabel(name: string, percent: number, description?: string): A11yProps {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
const label = description
|
||||
? `${name}: ${clamped} percent complete. ${description}`
|
||||
: `${name}: ${clamped} percent complete`;
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: 'progressbar',
|
||||
accessibilityValue: { min: 0, max: 100, now: clamped, text: `${clamped}%` },
|
||||
};
|
||||
}
|
||||
|
||||
export function sliderLabel(metric: string, value: number, max?: number): A11yProps {
|
||||
const label = max !== undefined ? `${metric}: ${value} of ${max}` : `${metric}: ${value}`;
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: 'adjustable',
|
||||
accessibilityValue: { now: value, max, text: String(value) },
|
||||
};
|
||||
}
|
||||
|
||||
export function alertLabel(severity: string, message: string): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `${severity} alert: ${message}`,
|
||||
accessibilityRole: 'alert',
|
||||
};
|
||||
}
|
||||
|
||||
export function achievementLabel(name: string, description: string, earned: boolean): A11yProps {
|
||||
const status = earned ? 'Earned' : 'Locked';
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `${status} achievement: ${name}. ${description}`,
|
||||
accessibilityRole: 'image',
|
||||
accessibilityState: { selected: earned },
|
||||
};
|
||||
}
|
||||
|
||||
export function streakLabel(current: number, longest: number): A11yProps {
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: `Current streak: ${current} days. Longest streak: ${longest} days`,
|
||||
accessibilityRole: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
export function listItemLabel(title: string, subtitle?: string, badge?: string): A11yProps {
|
||||
const parts = [title];
|
||||
if (subtitle) parts.push(subtitle);
|
||||
if (badge) parts.push(badge);
|
||||
return {
|
||||
accessible: true,
|
||||
accessibilityLabel: parts.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDurationForA11y(ms: number): string {
|
||||
const safeMs = Math.max(0, ms);
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
|
||||
if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
|
||||
if (seconds > 0 || parts.length === 0)
|
||||
parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
const ONES = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
|
||||
const TEENS = [
|
||||
'ten',
|
||||
'eleven',
|
||||
'twelve',
|
||||
'thirteen',
|
||||
'fourteen',
|
||||
'fifteen',
|
||||
'sixteen',
|
||||
'seventeen',
|
||||
'eighteen',
|
||||
'nineteen',
|
||||
];
|
||||
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
||||
|
||||
function numberToWords(n: number): string {
|
||||
if (n < 0) return `negative ${numberToWords(-n)}`;
|
||||
if (n === 0) return 'zero';
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = n;
|
||||
|
||||
if (remaining >= 1_000_000) {
|
||||
parts.push(`${numberToWords(Math.floor(remaining / 1_000_000))} million`);
|
||||
remaining %= 1_000_000;
|
||||
}
|
||||
if (remaining >= 1000) {
|
||||
parts.push(`${numberToWords(Math.floor(remaining / 1000))} thousand`);
|
||||
remaining %= 1000;
|
||||
}
|
||||
if (remaining >= 100) {
|
||||
parts.push(`${ONES[Math.floor(remaining / 100)]} hundred`);
|
||||
remaining %= 100;
|
||||
}
|
||||
if (remaining >= 20) {
|
||||
const t = TENS[Math.floor(remaining / 10)];
|
||||
const o = ONES[remaining % 10];
|
||||
parts.push(o ? `${t} ${o}` : t);
|
||||
} else if (remaining >= 10) {
|
||||
parts.push(TEENS[remaining - 10]);
|
||||
} else if (remaining > 0) {
|
||||
parts.push(ONES[remaining]);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatNumberForA11y(n: number): string {
|
||||
return numberToWords(Math.round(n));
|
||||
}
|
||||
|
||||
export function buildAnnouncement(headline: string, detail: string): string {
|
||||
return `${headline}. ${detail}`;
|
||||
}
|
||||
|
||||
const POSITIVE_BREAK_MESSAGES = [
|
||||
'Every bit of progress counts!',
|
||||
'You showed up today — that matters!',
|
||||
'Rest is part of the journey. You are doing great!',
|
||||
'You can always come back — no pressure!',
|
||||
'Progress, not perfection. Well done!',
|
||||
];
|
||||
|
||||
export function getPositiveBreakMessage(progressPercent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, progressPercent));
|
||||
const index = Math.floor((clamped / 100) * (POSITIVE_BREAK_MESSAGES.length - 1));
|
||||
return POSITIVE_BREAK_MESSAGES[index];
|
||||
}
|
||||
15
packages/accessibility/src/index.ts
Normal file
15
packages/accessibility/src/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export {
|
||||
buttonLabel,
|
||||
timerLabel,
|
||||
progressLabel,
|
||||
sliderLabel,
|
||||
alertLabel,
|
||||
achievementLabel,
|
||||
streakLabel,
|
||||
listItemLabel,
|
||||
formatDurationForA11y,
|
||||
formatNumberForA11y,
|
||||
buildAnnouncement,
|
||||
getPositiveBreakMessage,
|
||||
} from './client.js';
|
||||
export type { A11yProps } from './types.js';
|
||||
33
packages/accessibility/src/types.ts
Normal file
33
packages/accessibility/src/types.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Types for @bytelyst/accessibility.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export interface A11yProps {
|
||||
accessible: boolean;
|
||||
accessibilityLabel: string;
|
||||
accessibilityHint?: string;
|
||||
accessibilityRole?:
|
||||
| 'button'
|
||||
| 'header'
|
||||
| 'text'
|
||||
| 'timer'
|
||||
| 'progressbar'
|
||||
| 'image'
|
||||
| 'alert'
|
||||
| 'summary'
|
||||
| 'adjustable';
|
||||
accessibilityState?: {
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
checked?: boolean;
|
||||
busy?: boolean;
|
||||
expanded?: boolean;
|
||||
};
|
||||
accessibilityValue?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
now?: number;
|
||||
text?: string;
|
||||
};
|
||||
}
|
||||
10
packages/accessibility/tsconfig.json
Normal file
10
packages/accessibility/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/celebrations/package.json
Normal file
21
packages/celebrations/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/celebrations",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Product-agnostic celebration engine — milestones, haptics, confetti, positive messages",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
112
packages/celebrations/src/client.test.ts
Normal file
112
packages/celebrations/src/client.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createCelebrationEngine } from './client.js';
|
||||
import type { Celebration } from './types.js';
|
||||
|
||||
describe('createCelebrationEngine', () => {
|
||||
it('should return default celebration for known triggers', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const c = engine.getCelebration('level_up');
|
||||
expect(c.id).toBe('level_up');
|
||||
expect(c.title).toContain('Level Up');
|
||||
expect(c.confetti).toBe(true);
|
||||
expect(c.sound).toBe('level_up');
|
||||
});
|
||||
|
||||
it('should return fallback for unknown triggers', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const c = engine.getCelebration('custom_unknown');
|
||||
expect(c.id).toBe('custom_unknown');
|
||||
expect(c.title).toBe('Nice!');
|
||||
});
|
||||
|
||||
it('should use custom triggers when provided', () => {
|
||||
const custom: Celebration = {
|
||||
id: 'fasting_complete',
|
||||
title: 'Fast Complete!',
|
||||
body: 'You did it!',
|
||||
emoji: '🍃',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
};
|
||||
const engine = createCelebrationEngine({ customTriggers: { fasting_complete: custom } });
|
||||
const c = engine.getCelebration('fasting_complete');
|
||||
expect(c).toEqual(custom);
|
||||
});
|
||||
|
||||
it('should prefer custom triggers over defaults', () => {
|
||||
const custom: Celebration = {
|
||||
id: 'level_up',
|
||||
title: 'Custom Level Up!',
|
||||
body: 'Custom body',
|
||||
emoji: '🎮',
|
||||
hapticType: 'light',
|
||||
confetti: false,
|
||||
sound: 'none',
|
||||
};
|
||||
const engine = createCelebrationEngine({ customTriggers: { level_up: custom } });
|
||||
const c = engine.getCelebration('level_up');
|
||||
expect(c.title).toBe('Custom Level Up!');
|
||||
});
|
||||
|
||||
it('should return timed celebrations based on progress', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const shown = new Set<string>();
|
||||
|
||||
const at25 = engine.getTimedCelebrations(250, 1000, shown);
|
||||
expect(at25).toHaveLength(1);
|
||||
expect(at25[0].id).toBe('timed_25');
|
||||
shown.add('timed_25');
|
||||
|
||||
const at50 = engine.getTimedCelebrations(500, 1000, shown);
|
||||
expect(at50).toHaveLength(1);
|
||||
expect(at50[0].id).toBe('timed_50');
|
||||
});
|
||||
|
||||
it('should not repeat shown timed celebrations', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const shown = new Set(['timed_25', 'timed_50']);
|
||||
|
||||
const results = engine.getTimedCelebrations(500, 1000, shown);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty for zero target', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
expect(engine.getTimedCelebrations(100, 0, new Set())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect personal best', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
expect(engine.isPersonalBest(10, 5)).toBe(true);
|
||||
expect(engine.isPersonalBest(5, 10)).toBe(false);
|
||||
expect(engine.isPersonalBest(5, 5)).toBe(false);
|
||||
expect(engine.isPersonalBest(1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return positive messages', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msg0 = engine.getPositiveMessage(0);
|
||||
const msg100 = engine.getPositiveMessage(100);
|
||||
expect(msg0.length).toBeGreaterThan(0);
|
||||
expect(msg100.length).toBeGreaterThan(0);
|
||||
expect(msg0).not.toBe(msg100);
|
||||
});
|
||||
|
||||
it('should return positive incomplete messages', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msg = engine.getPositiveIncompleteMessage(30);
|
||||
expect(msg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should clamp progress percent', () => {
|
||||
const engine = createCelebrationEngine();
|
||||
const msgNeg = engine.getPositiveMessage(-10);
|
||||
const msg0 = engine.getPositiveMessage(0);
|
||||
expect(msgNeg).toBe(msg0);
|
||||
|
||||
const msgOver = engine.getPositiveMessage(200);
|
||||
const msg100 = engine.getPositiveMessage(100);
|
||||
expect(msgOver).toBe(msg100);
|
||||
});
|
||||
});
|
||||
225
packages/celebrations/src/client.ts
Normal file
225
packages/celebrations/src/client.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Product-agnostic celebration engine.
|
||||
*
|
||||
* Provides milestone triggers, haptic configs, confetti, sounds,
|
||||
* and positive reinforcement messages.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Celebration,
|
||||
CelebrationConfig,
|
||||
CelebrationEngine,
|
||||
CelebrationTrigger,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_CELEBRATIONS: Record<CelebrationTrigger, Celebration> = {
|
||||
task_completed: {
|
||||
id: 'task_completed',
|
||||
title: 'Task Done!',
|
||||
body: 'Great work completing that task!',
|
||||
emoji: '✅',
|
||||
hapticType: 'light',
|
||||
confetti: false,
|
||||
sound: 'chime',
|
||||
},
|
||||
streak_continued: {
|
||||
id: 'streak_continued',
|
||||
title: 'Streak Alive!',
|
||||
body: 'You kept your streak going — amazing consistency!',
|
||||
emoji: '🔥',
|
||||
hapticType: 'medium',
|
||||
confetti: false,
|
||||
sound: 'chime',
|
||||
},
|
||||
streak_milestone: {
|
||||
id: 'streak_milestone',
|
||||
title: 'Streak Milestone!',
|
||||
body: 'What an incredible streak — you are unstoppable!',
|
||||
emoji: '⭐',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
},
|
||||
achievement_unlocked: {
|
||||
id: 'achievement_unlocked',
|
||||
title: 'Achievement Unlocked!',
|
||||
body: 'You just unlocked a new achievement!',
|
||||
emoji: '🏆',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
},
|
||||
level_up: {
|
||||
id: 'level_up',
|
||||
title: 'Level Up!',
|
||||
body: 'You reached a new level — keep going!',
|
||||
emoji: '🚀',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'level_up',
|
||||
},
|
||||
personal_best: {
|
||||
id: 'personal_best',
|
||||
title: 'Personal Best!',
|
||||
body: 'You just set a new personal record!',
|
||||
emoji: '🥇',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
},
|
||||
milestone_reached: {
|
||||
id: 'milestone_reached',
|
||||
title: 'Milestone Reached!',
|
||||
body: 'Another milestone in the books — well done!',
|
||||
emoji: '🎯',
|
||||
hapticType: 'medium',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
},
|
||||
goal_completed: {
|
||||
id: 'goal_completed',
|
||||
title: 'Goal Complete!',
|
||||
body: 'You achieved your goal — celebrate this win!',
|
||||
emoji: '🎉',
|
||||
hapticType: 'heavy',
|
||||
confetti: true,
|
||||
sound: 'success',
|
||||
},
|
||||
first_action: {
|
||||
id: 'first_action',
|
||||
title: 'First Step!',
|
||||
body: 'Every journey begins with a single step — great start!',
|
||||
emoji: '👣',
|
||||
hapticType: 'medium',
|
||||
confetti: true,
|
||||
sound: 'chime',
|
||||
},
|
||||
halfway: {
|
||||
id: 'halfway',
|
||||
title: 'Halfway There!',
|
||||
body: "You're halfway through — the finish line is in sight!",
|
||||
emoji: '⏳',
|
||||
hapticType: 'medium',
|
||||
confetti: false,
|
||||
sound: 'chime',
|
||||
},
|
||||
session_completed: {
|
||||
id: 'session_completed',
|
||||
title: 'Session Complete!',
|
||||
body: 'Another session done — you showed up and that matters!',
|
||||
emoji: '💪',
|
||||
hapticType: 'medium',
|
||||
confetti: false,
|
||||
sound: 'success',
|
||||
},
|
||||
session_started: {
|
||||
id: 'session_started',
|
||||
title: 'Session Started!',
|
||||
body: 'You just started — showing up is the hardest part!',
|
||||
emoji: '🌟',
|
||||
hapticType: 'light',
|
||||
confetti: false,
|
||||
sound: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
const POSITIVE_MESSAGES: string[] = [
|
||||
"You're doing amazing!",
|
||||
'Keep it up — every step counts!',
|
||||
"Look at you go — you're incredible!",
|
||||
'Your dedication is inspiring!',
|
||||
'Consistency is your superpower!',
|
||||
"You're making real progress!",
|
||||
'One step at a time — and you nailed it!',
|
||||
'Be proud of how far you have come!',
|
||||
];
|
||||
|
||||
const POSITIVE_INCOMPLETE_MESSAGES: string[] = [
|
||||
'Every bit of progress counts!',
|
||||
'You showed up — that takes courage!',
|
||||
"It's okay to take a break — you'll come back stronger!",
|
||||
'Progress, not perfection!',
|
||||
'You started, and that matters most!',
|
||||
'Rest is part of the journey!',
|
||||
"You've done more than you think!",
|
||||
'Tomorrow is another opportunity!',
|
||||
];
|
||||
|
||||
const TIMED_MILESTONES = [
|
||||
{ fraction: 0.25, id: 'timed_25' },
|
||||
{ fraction: 0.5, id: 'timed_50' },
|
||||
{ fraction: 0.75, id: 'timed_75' },
|
||||
{ fraction: 1.0, id: 'timed_100' },
|
||||
];
|
||||
|
||||
const FALLBACK_CELEBRATION: Celebration = {
|
||||
id: 'generic',
|
||||
title: 'Nice!',
|
||||
body: 'Keep up the great work!',
|
||||
emoji: '🎉',
|
||||
hapticType: 'light',
|
||||
confetti: false,
|
||||
sound: 'chime',
|
||||
};
|
||||
|
||||
export function createCelebrationEngine(config?: CelebrationConfig): CelebrationEngine {
|
||||
const customTriggers = config?.customTriggers ?? {};
|
||||
|
||||
function getCelebration(trigger: CelebrationTrigger | string): Celebration {
|
||||
if (trigger in customTriggers) return customTriggers[trigger];
|
||||
if (trigger in DEFAULT_CELEBRATIONS) return DEFAULT_CELEBRATIONS[trigger as CelebrationTrigger];
|
||||
return { ...FALLBACK_CELEBRATION, id: trigger };
|
||||
}
|
||||
|
||||
function getTimedCelebrations(
|
||||
elapsedMs: number,
|
||||
targetMs: number,
|
||||
shownIds: Set<string>
|
||||
): Celebration[] {
|
||||
if (targetMs <= 0) return [];
|
||||
const progress = elapsedMs / targetMs;
|
||||
const results: Celebration[] = [];
|
||||
|
||||
for (const milestone of TIMED_MILESTONES) {
|
||||
if (progress >= milestone.fraction && !shownIds.has(milestone.id)) {
|
||||
const percent = Math.round(milestone.fraction * 100);
|
||||
results.push({
|
||||
id: milestone.id,
|
||||
title: `${percent}% Complete!`,
|
||||
body: getPositiveMessage(percent),
|
||||
emoji: milestone.fraction === 1.0 ? '🎉' : '⏳',
|
||||
hapticType: milestone.fraction === 1.0 ? 'heavy' : 'medium',
|
||||
confetti: milestone.fraction >= 0.75,
|
||||
sound: milestone.fraction === 1.0 ? 'success' : 'chime',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isPersonalBest(current: number, previous: number): boolean {
|
||||
return current > previous && previous >= 0;
|
||||
}
|
||||
|
||||
function getPositiveMessage(progressPercent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, progressPercent));
|
||||
const index = Math.floor((clamped / 100) * (POSITIVE_MESSAGES.length - 1));
|
||||
return POSITIVE_MESSAGES[index];
|
||||
}
|
||||
|
||||
function getPositiveIncompleteMessage(progressPercent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, progressPercent));
|
||||
const index = Math.floor((clamped / 100) * (POSITIVE_INCOMPLETE_MESSAGES.length - 1));
|
||||
return POSITIVE_INCOMPLETE_MESSAGES[index];
|
||||
}
|
||||
|
||||
return {
|
||||
getCelebration,
|
||||
getTimedCelebrations,
|
||||
isPersonalBest,
|
||||
getPositiveMessage,
|
||||
getPositiveIncompleteMessage,
|
||||
};
|
||||
}
|
||||
7
packages/celebrations/src/index.ts
Normal file
7
packages/celebrations/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { createCelebrationEngine } from './client.js';
|
||||
export type {
|
||||
Celebration,
|
||||
CelebrationConfig,
|
||||
CelebrationEngine,
|
||||
CelebrationTrigger,
|
||||
} from './types.js';
|
||||
41
packages/celebrations/src/types.ts
Normal file
41
packages/celebrations/src/types.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Types for @bytelyst/celebrations.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export type CelebrationTrigger =
|
||||
| 'task_completed'
|
||||
| 'streak_continued'
|
||||
| 'streak_milestone'
|
||||
| 'achievement_unlocked'
|
||||
| 'level_up'
|
||||
| 'personal_best'
|
||||
| 'milestone_reached'
|
||||
| 'goal_completed'
|
||||
| 'first_action'
|
||||
| 'halfway'
|
||||
| 'session_completed'
|
||||
| 'session_started';
|
||||
|
||||
export interface Celebration {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
emoji: string;
|
||||
hapticType: 'light' | 'medium' | 'heavy' | 'success' | 'warning';
|
||||
confetti: boolean;
|
||||
sound: 'chime' | 'success' | 'level_up' | 'none';
|
||||
}
|
||||
|
||||
export interface CelebrationConfig {
|
||||
/** Products register their own trigger→message mappings. */
|
||||
customTriggers?: Record<string, Celebration>;
|
||||
}
|
||||
|
||||
export interface CelebrationEngine {
|
||||
getCelebration(trigger: CelebrationTrigger | string): Celebration;
|
||||
getTimedCelebrations(elapsedMs: number, targetMs: number, shownIds: Set<string>): Celebration[];
|
||||
isPersonalBest(current: number, previous: number): boolean;
|
||||
getPositiveMessage(progressPercent: number): string;
|
||||
getPositiveIncompleteMessage(progressPercent: number): string;
|
||||
}
|
||||
10
packages/celebrations/tsconfig.json
Normal file
10
packages/celebrations/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/gentle-notifications/package.json
Normal file
21
packages/gentle-notifications/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/gentle-notifications",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Neurodivergent-friendly notification messaging — encouraging tone, adaptive frequency, forbidden phrases",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
97
packages/gentle-notifications/src/client.test.ts
Normal file
97
packages/gentle-notifications/src/client.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js';
|
||||
|
||||
describe('createGentleNotificationEngine', () => {
|
||||
it('should return default config', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
const config = engine.getDefaultConfig();
|
||||
expect(config.maxPerHour).toBe(3);
|
||||
expect(config.tone).toBe('encouraging');
|
||||
expect(config.adaptiveFrequency).toBe(true);
|
||||
expect(config.dismissCount).toBe(0);
|
||||
expect(config.suppressThreshold).toBe(5);
|
||||
});
|
||||
|
||||
it('should return a message for known type', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
const msg = engine.getMessage('reminder');
|
||||
expect(msg.title.length).toBeGreaterThan(0);
|
||||
expect(msg.body.length).toBeGreaterThan(0);
|
||||
expect(['encouraging', 'neutral', 'minimal']).toContain(msg.tone);
|
||||
});
|
||||
|
||||
it('should return fallback for unknown type', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
const msg = engine.getMessage('nonexistent_type');
|
||||
expect(msg.title).toBe('Hey');
|
||||
expect(msg.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should suppress when dismiss threshold reached', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
const config = { ...engine.getDefaultConfig(), dismissCount: 5 };
|
||||
expect(engine.shouldSuppress(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not suppress when below threshold', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
const config = { ...engine.getDefaultConfig(), dismissCount: 2 };
|
||||
expect(engine.shouldSuppress(config)).toBe(false);
|
||||
});
|
||||
|
||||
it('should record dismissal and reduce frequency', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
let config = engine.getDefaultConfig();
|
||||
expect(config.dismissCount).toBe(0);
|
||||
|
||||
config = engine.recordDismissal(config);
|
||||
expect(config.dismissCount).toBe(1);
|
||||
|
||||
// Record enough to trigger suppression
|
||||
for (let i = 0; i < 4; i++) {
|
||||
config = engine.recordDismissal(config);
|
||||
}
|
||||
expect(config.dismissCount).toBe(5);
|
||||
expect(config.maxPerHour).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it('should reset dismissals', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
let config = engine.getDefaultConfig();
|
||||
config = engine.recordDismissal(config);
|
||||
config = engine.recordDismissal(config);
|
||||
expect(config.dismissCount).toBe(2);
|
||||
|
||||
config = engine.resetDismissals(config);
|
||||
expect(config.dismissCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow registering custom messages', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
engine.registerMessages('fasting', [
|
||||
{ title: 'Fasting Reminder', body: 'Your body is doing great things!', tone: 'encouraging' },
|
||||
]);
|
||||
const msg = engine.getMessage('fasting');
|
||||
expect(msg.title).toBe('Fasting Reminder');
|
||||
});
|
||||
|
||||
it('should export FORBIDDEN_PHRASES', () => {
|
||||
expect(FORBIDDEN_PHRASES).toContain("You haven't");
|
||||
expect(FORBIDDEN_PHRASES).toContain('You failed');
|
||||
expect(FORBIDDEN_PHRASES.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('should detect forbidden phrases', () => {
|
||||
const engine = createGentleNotificationEngine();
|
||||
expect(engine.containsForbiddenPhrase("You haven't done this yet")).toBe(true);
|
||||
expect(engine.containsForbiddenPhrase('Great job today!')).toBe(false);
|
||||
expect(engine.containsForbiddenPhrase('you failed the test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect custom initial config', () => {
|
||||
const engine = createGentleNotificationEngine({ maxPerHour: 10, tone: 'minimal' });
|
||||
const config = engine.getDefaultConfig();
|
||||
expect(config.maxPerHour).toBe(10);
|
||||
expect(config.tone).toBe('minimal');
|
||||
});
|
||||
});
|
||||
157
packages/gentle-notifications/src/client.ts
Normal file
157
packages/gentle-notifications/src/client.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Neurodivergent-friendly notification messaging system.
|
||||
*
|
||||
* Encouraging tone, adaptive frequency, forbidden phrases.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js';
|
||||
|
||||
export const FORBIDDEN_PHRASES: readonly string[] = [
|
||||
"You haven't",
|
||||
'You forgot',
|
||||
"Don't forget",
|
||||
'You should have',
|
||||
"Why didn't you",
|
||||
'You missed',
|
||||
'You failed',
|
||||
'You need to',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_MESSAGES: Record<string, GentleMessage[]> = {
|
||||
reminder: [
|
||||
{
|
||||
title: 'Gentle Reminder',
|
||||
body: 'Whenever you are ready, there is something waiting for you.',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
{
|
||||
title: 'Quick Note',
|
||||
body: 'No rush — just a friendly nudge when the time feels right.',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
{
|
||||
title: 'Hey there',
|
||||
body: 'Take your time. We will be here when you are ready.',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
progress: [
|
||||
{
|
||||
title: 'Nice Progress',
|
||||
body: 'Look at what you have accomplished — every step matters!',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
{
|
||||
title: 'Moving Forward',
|
||||
body: 'You are making progress at your own pace. That is perfect.',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
],
|
||||
check_in: [
|
||||
{
|
||||
title: 'Check In',
|
||||
body: 'How are you feeling? Remember, there is no wrong answer.',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
{ title: 'Quick Check', body: 'Just checking in — hope you are doing well!', tone: 'neutral' },
|
||||
],
|
||||
streak: [
|
||||
{
|
||||
title: 'Streak Update',
|
||||
body: 'Your consistency is impressive — keep it going if it feels right!',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
],
|
||||
idle: [
|
||||
{
|
||||
title: 'Welcome Back',
|
||||
body: 'Great to see you again — no judgment, just glad you are here!',
|
||||
tone: 'encouraging',
|
||||
},
|
||||
{
|
||||
title: 'Hi Again',
|
||||
body: 'Whenever you are ready to jump back in, we are here.',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function createGentleNotificationEngine(
|
||||
initialConfig?: Partial<GentleNotificationConfig>
|
||||
): GentleNotificationEngine {
|
||||
const messagePools: Record<string, GentleMessage[]> = { ...DEFAULT_MESSAGES };
|
||||
|
||||
function getDefaultConfig(): GentleNotificationConfig {
|
||||
return {
|
||||
maxPerHour: 3,
|
||||
tone: 'encouraging',
|
||||
adaptiveFrequency: true,
|
||||
dismissCount: 0,
|
||||
suppressThreshold: 5,
|
||||
...initialConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function getMessage(type: string, config?: GentleNotificationConfig): GentleMessage {
|
||||
const tone = config?.tone ?? 'encouraging';
|
||||
const pool = messagePools[type];
|
||||
|
||||
if (!pool || pool.length === 0) {
|
||||
return {
|
||||
title: 'Hey',
|
||||
body: 'Hope you are having a good day!',
|
||||
tone,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter by tone if possible, fallback to any
|
||||
const toneFiltered = pool.filter(m => m.tone === tone);
|
||||
const candidates = toneFiltered.length > 0 ? toneFiltered : pool;
|
||||
const index = Math.floor(Math.random() * candidates.length);
|
||||
return candidates[index];
|
||||
}
|
||||
|
||||
function shouldSuppress(config: GentleNotificationConfig): boolean {
|
||||
if (config.adaptiveFrequency && config.dismissCount >= config.suppressThreshold) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig {
|
||||
const newConfig = { ...config, dismissCount: config.dismissCount + 1 };
|
||||
if (newConfig.adaptiveFrequency && newConfig.dismissCount >= newConfig.suppressThreshold) {
|
||||
newConfig.maxPerHour = Math.max(1, Math.floor(newConfig.maxPerHour / 2));
|
||||
}
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
function resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig {
|
||||
return { ...config, dismissCount: 0 };
|
||||
}
|
||||
|
||||
function registerMessages(type: string, messages: GentleMessage[]): void {
|
||||
messagePools[type] = [...(messagePools[type] ?? []), ...messages];
|
||||
}
|
||||
|
||||
function getForbiddenPhrases(): readonly string[] {
|
||||
return FORBIDDEN_PHRASES;
|
||||
}
|
||||
|
||||
function containsForbiddenPhrase(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return FORBIDDEN_PHRASES.some(phrase => lower.includes(phrase.toLowerCase()));
|
||||
}
|
||||
|
||||
return {
|
||||
getDefaultConfig,
|
||||
getMessage,
|
||||
shouldSuppress,
|
||||
recordDismissal,
|
||||
resetDismissals,
|
||||
registerMessages,
|
||||
getForbiddenPhrases,
|
||||
containsForbiddenPhrase,
|
||||
};
|
||||
}
|
||||
2
packages/gentle-notifications/src/index.ts
Normal file
2
packages/gentle-notifications/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js';
|
||||
export type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js';
|
||||
29
packages/gentle-notifications/src/types.ts
Normal file
29
packages/gentle-notifications/src/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Types for @bytelyst/gentle-notifications.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export interface GentleNotificationConfig {
|
||||
maxPerHour: number;
|
||||
tone: 'encouraging' | 'neutral' | 'minimal';
|
||||
adaptiveFrequency: boolean;
|
||||
dismissCount: number;
|
||||
suppressThreshold: number;
|
||||
}
|
||||
|
||||
export interface GentleMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
tone: 'encouraging' | 'neutral' | 'minimal';
|
||||
}
|
||||
|
||||
export interface GentleNotificationEngine {
|
||||
getDefaultConfig(): GentleNotificationConfig;
|
||||
getMessage(type: string, config?: GentleNotificationConfig): GentleMessage;
|
||||
shouldSuppress(config: GentleNotificationConfig): boolean;
|
||||
recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig;
|
||||
resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig;
|
||||
registerMessages(type: string, messages: GentleMessage[]): void;
|
||||
getForbiddenPhrases(): readonly string[];
|
||||
containsForbiddenPhrase(text: string): boolean;
|
||||
}
|
||||
10
packages/gentle-notifications/tsconfig.json
Normal file
10
packages/gentle-notifications/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/marketplace-client/package.json
Normal file
21
packages/marketplace-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/marketplace-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe marketplace client for platform-service listings, reviews, installs",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
282
packages/marketplace-client/src/client.test.ts
Normal file
282
packages/marketplace-client/src/client.test.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createMarketplaceClient } from './client.js';
|
||||
import type {
|
||||
MarketplaceListingDoc,
|
||||
MarketplaceReviewDoc,
|
||||
MarketplaceInstallDoc,
|
||||
} from './types.js';
|
||||
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAccessToken: () => 'test-token',
|
||||
};
|
||||
|
||||
function mockListing(overrides?: Partial<MarketplaceListingDoc>): MarketplaceListingDoc {
|
||||
return {
|
||||
id: 'lst_1',
|
||||
productId: 'testapp',
|
||||
templateType: 'fasting_protocol',
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
title: '16:8 Protocol',
|
||||
shortDescription: 'Classic intermittent fasting',
|
||||
description: 'A detailed 16:8 fasting protocol.',
|
||||
tags: ['fasting', 'beginner'],
|
||||
category: 'protocols',
|
||||
payload: { hours: 16, breakHours: 8 },
|
||||
pricingModel: 'free',
|
||||
priceInCents: 0,
|
||||
certificationStatus: 'approved',
|
||||
installCount: 100,
|
||||
reviewCount: 10,
|
||||
averageRating: 4.5,
|
||||
visibility: 'public',
|
||||
featured: false,
|
||||
version: '1.0.0',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockReview(overrides?: Partial<MarketplaceReviewDoc>): MarketplaceReviewDoc {
|
||||
return {
|
||||
id: 'rev_1',
|
||||
listingId: 'lst_1',
|
||||
productId: 'testapp',
|
||||
authorId: 'user-2',
|
||||
rating: 5,
|
||||
title: 'Great protocol!',
|
||||
body: 'Really works well for me.',
|
||||
verified: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockInstall(overrides?: Partial<MarketplaceInstallDoc>): MarketplaceInstallDoc {
|
||||
return {
|
||||
id: 'inst_1',
|
||||
listingId: 'lst_1',
|
||||
productId: 'testapp',
|
||||
userId: 'user-2',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-01-01T00:00:00Z',
|
||||
uninstalledAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createMarketplaceClient', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should list listings', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ listings: [mockListing()], total: 1 }),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.listListings({ category: 'protocols' });
|
||||
expect(result.listings).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain('category=protocols');
|
||||
});
|
||||
|
||||
it('should get a listing by id', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockListing()),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.getListing('lst_1');
|
||||
expect(result.title).toBe('16:8 Protocol');
|
||||
});
|
||||
|
||||
it('should create a listing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockListing()),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.createListing({
|
||||
templateType: 'fasting_protocol',
|
||||
title: '16:8 Protocol',
|
||||
shortDescription: 'Classic',
|
||||
description: 'Detailed',
|
||||
category: 'protocols',
|
||||
payload: { hours: 16 },
|
||||
});
|
||||
expect(result.id).toBe('lst_1');
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/marketplace/listings'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should submit for certification', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockListing({ certificationStatus: 'submitted' })),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.submitForCertification('lst_1', 'Ready for review');
|
||||
expect(result.certificationStatus).toBe('submitted');
|
||||
});
|
||||
|
||||
it('should install a listing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockInstall()),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.installListing('lst_1');
|
||||
expect(result.listingId).toBe('lst_1');
|
||||
});
|
||||
|
||||
it('should list my installs', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockInstall()]),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.listMyInstalls();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should list reviews', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockReview()]),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.listReviews('lst_1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].rating).toBe(5);
|
||||
});
|
||||
|
||||
it('should create a review', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReview()),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.createReview('lst_1', {
|
||||
rating: 5,
|
||||
title: 'Great!',
|
||||
body: 'Love it.',
|
||||
});
|
||||
expect(result.rating).toBe(5);
|
||||
});
|
||||
|
||||
it('should update a listing', async () => {
|
||||
const updated = mockListing({ title: 'Updated Title' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updated),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
const result = await client.updateListing('lst_1', { title: 'Updated Title' });
|
||||
expect(result.title).toBe('Updated Title');
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/marketplace/listings/lst_1',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should uninstall a listing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
await expect(client.uninstallListing('lst_1')).resolves.toBeUndefined();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/marketplace/listings/lst_1/uninstall',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should report a listing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
await expect(
|
||||
client.reportListing('lst_1', { reason: 'spam', details: 'Looks like spam' })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on non-ok response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
await expect(client.getListing('lst_1')).rejects.toThrow('getListing failed: 500');
|
||||
});
|
||||
|
||||
it('should send correct headers', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ listings: [], total: 0 }),
|
||||
})
|
||||
);
|
||||
const client = createMarketplaceClient(baseConfig);
|
||||
await client.listListings();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const callHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(callHeaders['x-product-id']).toBe('testapp');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer test-token');
|
||||
expect(callHeaders['x-request-id']).toBeDefined();
|
||||
});
|
||||
});
|
||||
219
packages/marketplace-client/src/client.ts
Normal file
219
packages/marketplace-client/src/client.ts
Normal file
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Browser/React Native-safe marketplace client for platform-service.
|
||||
*
|
||||
* Wraps platform-service /marketplace/* endpoints.
|
||||
* No Node.js dependencies — uses globalThis.fetch.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateListingInput,
|
||||
MarketplaceClient,
|
||||
MarketplaceClientConfig,
|
||||
MarketplaceInstallDoc,
|
||||
MarketplaceListingDoc,
|
||||
MarketplaceReviewDoc,
|
||||
} 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 createMarketplaceClient(config: MarketplaceClientConfig): MarketplaceClient {
|
||||
const { baseUrl, productId, getAccessToken } = config;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Listings ──────────────────────────────────────
|
||||
|
||||
async function listListings(query?: {
|
||||
templateType?: string;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
pricingModel?: string;
|
||||
sortBy?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ listings: MarketplaceListingDoc[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.templateType) params.set('templateType', query.templateType);
|
||||
if (query?.category) params.set('category', query.category);
|
||||
if (query?.tags) params.set('tags', query.tags);
|
||||
if (query?.pricingModel) params.set('pricingModel', query.pricingModel);
|
||||
if (query?.sortBy) params.set('sortBy', query.sortBy);
|
||||
if (query?.q) params.set('q', query.q);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
if (query?.offset) params.set('offset', String(query.offset));
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${baseUrl}/marketplace/listings?${qs}` : `${baseUrl}/marketplace/listings`;
|
||||
const res = await globalThis.fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`listListings failed: ${res.status}`);
|
||||
return (await res.json()) as { listings: MarketplaceListingDoc[]; total: number };
|
||||
}
|
||||
|
||||
async function getListing(id: string): Promise<MarketplaceListingDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`,
|
||||
{ headers: headers() }
|
||||
);
|
||||
if (!res.ok) throw new Error(`getListing failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceListingDoc;
|
||||
}
|
||||
|
||||
async function createListing(input: CreateListingInput): Promise<MarketplaceListingDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/marketplace/listings`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`createListing failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceListingDoc;
|
||||
}
|
||||
|
||||
async function updateListing(
|
||||
id: string,
|
||||
updates: Partial<MarketplaceListingDoc>
|
||||
): Promise<MarketplaceListingDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`updateListing failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceListingDoc;
|
||||
}
|
||||
|
||||
async function submitForCertification(
|
||||
id: string,
|
||||
notes?: string
|
||||
): Promise<MarketplaceListingDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(id)}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ notes }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`submitForCertification failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceListingDoc;
|
||||
}
|
||||
|
||||
// ── Installs ──────────────────────────────────────
|
||||
|
||||
async function installListing(listingId: string): Promise<MarketplaceInstallDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/install`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`installListing failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceInstallDoc;
|
||||
}
|
||||
|
||||
async function uninstallListing(listingId: string): Promise<void> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/uninstall`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`uninstallListing failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function listMyInstalls(query?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MarketplaceInstallDoc[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
if (query?.offset) params.set('offset', String(query.offset));
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${baseUrl}/marketplace/installs?${qs}` : `${baseUrl}/marketplace/installs`;
|
||||
const res = await globalThis.fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`listMyInstalls failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceInstallDoc[];
|
||||
}
|
||||
|
||||
// ── Reviews ───────────────────────────────────────
|
||||
|
||||
async function listReviews(
|
||||
listingId: string,
|
||||
query?: { sortBy?: string; limit?: number }
|
||||
): Promise<MarketplaceReviewDoc[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.sortBy) params.set('sortBy', query.sortBy);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
const url = qs
|
||||
? `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews?${qs}`
|
||||
: `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`;
|
||||
const res = await globalThis.fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`listReviews failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceReviewDoc[];
|
||||
}
|
||||
|
||||
async function createReview(
|
||||
listingId: string,
|
||||
input: { rating: number; title: string; body: string }
|
||||
): Promise<MarketplaceReviewDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`createReview failed: ${res.status}`);
|
||||
return (await res.json()) as MarketplaceReviewDoc;
|
||||
}
|
||||
|
||||
// ── Reports ───────────────────────────────────────
|
||||
|
||||
async function reportListing(
|
||||
listingId: string,
|
||||
input: { reason: string; details: string }
|
||||
): Promise<void> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/report`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`reportListing failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
listListings,
|
||||
getListing,
|
||||
createListing,
|
||||
updateListing,
|
||||
submitForCertification,
|
||||
installListing,
|
||||
uninstallListing,
|
||||
listMyInstalls,
|
||||
listReviews,
|
||||
createReview,
|
||||
reportListing,
|
||||
};
|
||||
}
|
||||
9
packages/marketplace-client/src/index.ts
Normal file
9
packages/marketplace-client/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { createMarketplaceClient } from './client.js';
|
||||
export type {
|
||||
MarketplaceClient,
|
||||
MarketplaceClientConfig,
|
||||
MarketplaceListingDoc,
|
||||
MarketplaceReviewDoc,
|
||||
MarketplaceInstallDoc,
|
||||
CreateListingInput,
|
||||
} from './types.js';
|
||||
115
packages/marketplace-client/src/types.ts
Normal file
115
packages/marketplace-client/src/types.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Types for @bytelyst/marketplace-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface MarketplaceListingDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
templateType: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
payload: Record<string, unknown>;
|
||||
pricingModel: 'free' | 'paid' | 'freemium';
|
||||
priceInCents: number;
|
||||
certificationStatus: 'draft' | 'submitted' | 'in_review' | 'approved' | 'rejected' | 'suspended';
|
||||
installCount: number;
|
||||
reviewCount: number;
|
||||
averageRating: number;
|
||||
visibility: 'private' | 'unlisted' | 'public';
|
||||
featured: boolean;
|
||||
version: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceReviewDoc {
|
||||
id: string;
|
||||
listingId: string;
|
||||
productId: string;
|
||||
authorId: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
body: string;
|
||||
verified: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceInstallDoc {
|
||||
id: string;
|
||||
listingId: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
uninstalledAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateListingInput {
|
||||
templateType: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
category: string;
|
||||
payload: Record<string, unknown>;
|
||||
pricingModel?: 'free' | 'paid' | 'freemium';
|
||||
priceInCents?: number;
|
||||
visibility?: 'private' | 'unlisted' | 'public';
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header on every request. */
|
||||
productId: string;
|
||||
|
||||
/** Returns a JWT access token, or null if not authenticated. */
|
||||
getAccessToken: () => string | null;
|
||||
}
|
||||
|
||||
export interface MarketplaceClient {
|
||||
// Listings
|
||||
listListings(query?: {
|
||||
templateType?: string;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
pricingModel?: string;
|
||||
sortBy?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ listings: MarketplaceListingDoc[]; total: number }>;
|
||||
getListing(id: string): Promise<MarketplaceListingDoc>;
|
||||
createListing(input: CreateListingInput): Promise<MarketplaceListingDoc>;
|
||||
updateListing(
|
||||
id: string,
|
||||
updates: Partial<MarketplaceListingDoc>
|
||||
): Promise<MarketplaceListingDoc>;
|
||||
submitForCertification(id: string, notes?: string): Promise<MarketplaceListingDoc>;
|
||||
|
||||
// Installs
|
||||
installListing(listingId: string): Promise<MarketplaceInstallDoc>;
|
||||
uninstallListing(listingId: string): Promise<void>;
|
||||
listMyInstalls(query?: { limit?: number; offset?: number }): Promise<MarketplaceInstallDoc[]>;
|
||||
|
||||
// Reviews
|
||||
listReviews(
|
||||
listingId: string,
|
||||
query?: { sortBy?: string; limit?: number }
|
||||
): Promise<MarketplaceReviewDoc[]>;
|
||||
createReview(
|
||||
listingId: string,
|
||||
input: { rating: number; title: string; body: string }
|
||||
): Promise<MarketplaceReviewDoc>;
|
||||
|
||||
// Reports
|
||||
reportListing(listingId: string, input: { reason: string; details: string }): Promise<void>;
|
||||
}
|
||||
10
packages/marketplace-client/tsconfig.json
Normal file
10
packages/marketplace-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/org-client/package.json
Normal file
21
packages/org-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/org-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe org, workspace, membership, and license client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
289
packages/org-client/src/client.test.ts
Normal file
289
packages/org-client/src/client.test.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createOrgClient } from './client.js';
|
||||
import type { OrganizationDoc, WorkspaceDoc, MembershipDoc, LicenseDoc } from './types.js';
|
||||
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAccessToken: () => 'admin-token',
|
||||
};
|
||||
|
||||
function mockOrg(overrides?: Partial<OrganizationDoc>): OrganizationDoc {
|
||||
return {
|
||||
id: 'org_1',
|
||||
productId: 'testapp',
|
||||
name: 'Test Org',
|
||||
slug: 'test-org',
|
||||
status: 'active',
|
||||
ownerUserId: 'user-1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockWorkspace(overrides?: Partial<WorkspaceDoc>): WorkspaceDoc {
|
||||
return {
|
||||
id: 'ws_1',
|
||||
orgId: 'org_1',
|
||||
productId: 'testapp',
|
||||
name: 'Engineering',
|
||||
slug: 'engineering',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockMembership(overrides?: Partial<MembershipDoc>): MembershipDoc {
|
||||
return {
|
||||
id: 'mbr_org1_user1_org',
|
||||
orgId: 'org_1',
|
||||
productId: 'testapp',
|
||||
scope: 'org',
|
||||
userId: 'user-1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockLicense(overrides?: Partial<LicenseDoc>): LicenseDoc {
|
||||
return {
|
||||
id: 'lic_1',
|
||||
productId: 'testapp',
|
||||
key: 'LYSNR-XXXX-YYYY-ZZZZ',
|
||||
userId: 'user-1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
activatedAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: null,
|
||||
deviceIds: ['device-1'],
|
||||
maxDevices: 3,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createOrgClient', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should list orgs', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockOrg()]),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.listOrgs();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Test Org');
|
||||
});
|
||||
|
||||
it('should create an org', async () => {
|
||||
const org = mockOrg();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(org),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.createOrg({ name: 'Test Org', slug: 'test-org' });
|
||||
expect(result.id).toBe('org_1');
|
||||
});
|
||||
|
||||
it('should get an org by id', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockOrg()),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.getOrg('org_1');
|
||||
expect(result.slug).toBe('test-org');
|
||||
});
|
||||
|
||||
it('should list workspaces for an org', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockWorkspace()]),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.listWorkspaces('org_1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Engineering');
|
||||
});
|
||||
|
||||
it('should create a workspace', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockWorkspace()),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.createWorkspace('org_1', {
|
||||
name: 'Engineering',
|
||||
slug: 'engineering',
|
||||
});
|
||||
expect(result.orgId).toBe('org_1');
|
||||
});
|
||||
|
||||
it('should list memberships', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([mockMembership()]),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.listMemberships('org_1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].role).toBe('admin');
|
||||
});
|
||||
|
||||
it('should add a member', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMembership()),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.addMember('org_1', { userId: 'user-1', role: 'admin' });
|
||||
expect(result.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('should generate a license', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockLicense()),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.generateLicense({ userId: 'user-1', plan: 'pro' });
|
||||
expect(result.key).toContain('LYSNR');
|
||||
});
|
||||
|
||||
it('should activate a license', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockLicense()),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.activateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' });
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should update an org', async () => {
|
||||
const org = mockOrg({ name: 'Updated Org' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(org),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.updateOrg('org_1', { name: 'Updated Org' });
|
||||
expect(result.name).toBe('Updated Org');
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/orgs/org_1',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a workspace', async () => {
|
||||
const ws = mockWorkspace({ name: 'Renamed' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(ws),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
const result = await client.updateWorkspace('org_1', 'ws_1', { name: 'Renamed' });
|
||||
expect(result.name).toBe('Renamed');
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/orgs/org_1/workspaces/ws_1',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should deactivate a license', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
await expect(
|
||||
client.deactivateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/licenses/deactivate',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw 403 for non-admin', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
await expect(client.listOrgs()).rejects.toThrow('listOrgs failed: 403');
|
||||
});
|
||||
|
||||
it('should send correct headers', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
);
|
||||
const client = createOrgClient(baseConfig);
|
||||
await client.listOrgs();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const callHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(callHeaders['x-product-id']).toBe('testapp');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer admin-token');
|
||||
expect(callHeaders['x-request-id']).toBeDefined();
|
||||
});
|
||||
});
|
||||
224
packages/org-client/src/client.ts
Normal file
224
packages/org-client/src/client.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Browser/React Native-safe org, workspace, membership, and license client
|
||||
* for platform-service.
|
||||
*
|
||||
* All org routes require admin-only access (super_admin or admin JWT role).
|
||||
* No Node.js dependencies — uses globalThis.fetch.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LicenseDoc,
|
||||
MembershipDoc,
|
||||
OrgClient,
|
||||
OrgClientConfig,
|
||||
OrganizationDoc,
|
||||
WorkspaceDoc,
|
||||
} 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 createOrgClient(config: OrgClientConfig): OrgClient {
|
||||
const { baseUrl, productId, getAccessToken } = config;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Organizations ─────────────────────────────────
|
||||
|
||||
async function listOrgs(query?: { status?: string; limit?: number }): Promise<OrganizationDoc[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${baseUrl}/orgs?${qs}` : `${baseUrl}/orgs`;
|
||||
const res = await globalThis.fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`listOrgs failed: ${res.status}`);
|
||||
return (await res.json()) as OrganizationDoc[];
|
||||
}
|
||||
|
||||
async function createOrg(input: {
|
||||
name: string;
|
||||
slug: string;
|
||||
ownerUserId?: string;
|
||||
}): Promise<OrganizationDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ ...input, productId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`createOrg failed: ${res.status}`);
|
||||
return (await res.json()) as OrganizationDoc;
|
||||
}
|
||||
|
||||
async function getOrg(id: string): Promise<OrganizationDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, {
|
||||
headers: headers(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`getOrg failed: ${res.status}`);
|
||||
return (await res.json()) as OrganizationDoc;
|
||||
}
|
||||
|
||||
async function updateOrg(
|
||||
id: string,
|
||||
updates: Partial<OrganizationDoc>
|
||||
): Promise<OrganizationDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error(`updateOrg failed: ${res.status}`);
|
||||
return (await res.json()) as OrganizationDoc;
|
||||
}
|
||||
|
||||
// ── Workspaces ────────────────────────────────────
|
||||
|
||||
async function listWorkspaces(orgId: string): Promise<WorkspaceDoc[]> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, {
|
||||
headers: headers(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`listWorkspaces failed: ${res.status}`);
|
||||
return (await res.json()) as WorkspaceDoc[];
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
orgId: string,
|
||||
input: { name: string; slug: string; description?: string }
|
||||
): Promise<WorkspaceDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`createWorkspace failed: ${res.status}`);
|
||||
return (await res.json()) as WorkspaceDoc;
|
||||
}
|
||||
|
||||
async function updateWorkspace(
|
||||
orgId: string,
|
||||
workspaceId: string,
|
||||
updates: Partial<WorkspaceDoc>
|
||||
): Promise<WorkspaceDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(workspaceId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`updateWorkspace failed: ${res.status}`);
|
||||
return (await res.json()) as WorkspaceDoc;
|
||||
}
|
||||
|
||||
// ── Memberships ───────────────────────────────────
|
||||
|
||||
async function listMemberships(
|
||||
orgId: string,
|
||||
query?: { scope?: string; limit?: number }
|
||||
): Promise<MembershipDoc[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.scope) params.set('scope', query.scope);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
const url = qs
|
||||
? `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships?${qs}`
|
||||
: `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`;
|
||||
const res = await globalThis.fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`listMemberships failed: ${res.status}`);
|
||||
return (await res.json()) as MembershipDoc[];
|
||||
}
|
||||
|
||||
async function addMember(
|
||||
orgId: string,
|
||||
input: { userId: string; role?: string; scope?: string; workspaceId?: string }
|
||||
): Promise<MembershipDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`addMember failed: ${res.status}`);
|
||||
return (await res.json()) as MembershipDoc;
|
||||
}
|
||||
|
||||
async function updateMember(
|
||||
orgId: string,
|
||||
membershipId: string,
|
||||
updates: { role?: string; status?: string }
|
||||
): Promise<MembershipDoc> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships/${encodeURIComponent(membershipId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`updateMember failed: ${res.status}`);
|
||||
return (await res.json()) as MembershipDoc;
|
||||
}
|
||||
|
||||
// ── Licenses ──────────────────────────────────────
|
||||
|
||||
async function generateLicense(input: {
|
||||
userId: string;
|
||||
plan: string;
|
||||
maxDevices?: number;
|
||||
}): Promise<LicenseDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/licenses`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ ...input, productId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`generateLicense failed: ${res.status}`);
|
||||
return (await res.json()) as LicenseDoc;
|
||||
}
|
||||
|
||||
async function activateLicense(input: { key: string; deviceId: string }): Promise<LicenseDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/licenses/activate`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`activateLicense failed: ${res.status}`);
|
||||
return (await res.json()) as LicenseDoc;
|
||||
}
|
||||
|
||||
async function deactivateLicense(input: { key: string; deviceId: string }): Promise<void> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/licenses/deactivate`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error(`deactivateLicense failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
listOrgs,
|
||||
createOrg,
|
||||
getOrg,
|
||||
updateOrg,
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
updateWorkspace,
|
||||
listMemberships,
|
||||
addMember,
|
||||
updateMember,
|
||||
generateLicense,
|
||||
activateLicense,
|
||||
deactivateLicense,
|
||||
};
|
||||
}
|
||||
9
packages/org-client/src/index.ts
Normal file
9
packages/org-client/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { createOrgClient } from './client.js';
|
||||
export type {
|
||||
OrgClient,
|
||||
OrgClientConfig,
|
||||
OrganizationDoc,
|
||||
WorkspaceDoc,
|
||||
MembershipDoc,
|
||||
LicenseDoc,
|
||||
} from './types.js';
|
||||
113
packages/org-client/src/types.ts
Normal file
113
packages/org-client/src/types.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Types for @bytelyst/org-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface OrganizationDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'disabled';
|
||||
ownerUserId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDoc {
|
||||
id: string;
|
||||
orgId: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'archived';
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MembershipDoc {
|
||||
id: string;
|
||||
orgId: string;
|
||||
productId: string;
|
||||
scope: 'org' | 'workspace';
|
||||
workspaceId?: string;
|
||||
userId: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
status: 'active' | 'invited' | 'disabled';
|
||||
invitedBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LicenseDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
plan: 'free' | 'pro' | 'enterprise';
|
||||
status: 'active' | 'revoked' | 'expired';
|
||||
activatedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
deviceIds: string[];
|
||||
maxDevices: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrgClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header on every request. */
|
||||
productId: string;
|
||||
|
||||
/** Returns a JWT access token (admin role required), or null. */
|
||||
getAccessToken: () => string | null;
|
||||
}
|
||||
|
||||
export interface OrgClient {
|
||||
// Organizations
|
||||
listOrgs(query?: { status?: string; limit?: number }): Promise<OrganizationDoc[]>;
|
||||
createOrg(input: { name: string; slug: string; ownerUserId?: string }): Promise<OrganizationDoc>;
|
||||
getOrg(id: string): Promise<OrganizationDoc>;
|
||||
updateOrg(id: string, updates: Partial<OrganizationDoc>): Promise<OrganizationDoc>;
|
||||
|
||||
// Workspaces
|
||||
listWorkspaces(orgId: string): Promise<WorkspaceDoc[]>;
|
||||
createWorkspace(
|
||||
orgId: string,
|
||||
input: { name: string; slug: string; description?: string }
|
||||
): Promise<WorkspaceDoc>;
|
||||
updateWorkspace(
|
||||
orgId: string,
|
||||
workspaceId: string,
|
||||
updates: Partial<WorkspaceDoc>
|
||||
): Promise<WorkspaceDoc>;
|
||||
|
||||
// Memberships
|
||||
listMemberships(
|
||||
orgId: string,
|
||||
query?: { scope?: string; limit?: number }
|
||||
): Promise<MembershipDoc[]>;
|
||||
addMember(
|
||||
orgId: string,
|
||||
input: { userId: string; role?: string; scope?: string; workspaceId?: string }
|
||||
): Promise<MembershipDoc>;
|
||||
updateMember(
|
||||
orgId: string,
|
||||
membershipId: string,
|
||||
updates: { role?: string; status?: string }
|
||||
): Promise<MembershipDoc>;
|
||||
|
||||
// Licenses
|
||||
generateLicense(input: {
|
||||
userId: string;
|
||||
plan: string;
|
||||
maxDevices?: number;
|
||||
}): Promise<LicenseDoc>;
|
||||
activateLicense(input: { key: string; deviceId: string }): Promise<LicenseDoc>;
|
||||
deactivateLicense(input: { key: string; deviceId: string }): Promise<void>;
|
||||
}
|
||||
10
packages/org-client/tsconfig.json
Normal file
10
packages/org-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/quick-actions/package.json
Normal file
21
packages/quick-actions/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/quick-actions",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Progressive disclosure system, smart defaults, quick action definitions",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
117
packages/quick-actions/src/client.test.ts
Normal file
117
packages/quick-actions/src/client.test.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getVisibleSections,
|
||||
getAvailableActions,
|
||||
pickSmartDefault,
|
||||
MAX_VISIBLE_ITEMS,
|
||||
MAX_VISIBLE_LIST,
|
||||
} from './client.js';
|
||||
import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js';
|
||||
|
||||
describe('getVisibleSections', () => {
|
||||
const sections: ProgressiveSection[] = [
|
||||
{ id: 'main', title: 'Main', defaultExpanded: true, priority: 'primary' },
|
||||
{ id: 'stats', title: 'Stats', defaultExpanded: false, priority: 'secondary' },
|
||||
{ id: 'details', title: 'Details', defaultExpanded: false, priority: 'detail' },
|
||||
{ id: 'advanced', title: 'Advanced', defaultExpanded: false, priority: 'primary' },
|
||||
];
|
||||
|
||||
it('should show primary and defaultExpanded sections', () => {
|
||||
const visible = getVisibleSections(sections, new Set());
|
||||
expect(visible.map(s => s.id)).toEqual(['main', 'advanced']);
|
||||
});
|
||||
|
||||
it('should include explicitly expanded sections', () => {
|
||||
const visible = getVisibleSections(sections, new Set(['stats']));
|
||||
expect(visible.map(s => s.id)).toEqual(['main', 'stats', 'advanced']);
|
||||
});
|
||||
|
||||
it('should return empty for no sections', () => {
|
||||
expect(getVisibleSections([], new Set())).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableActions', () => {
|
||||
const actions: QuickAction[] = [
|
||||
{
|
||||
id: 'start',
|
||||
label: 'Start',
|
||||
icon: 'play',
|
||||
shortLabel: 'Start',
|
||||
action: 'start',
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
label: 'Share',
|
||||
icon: 'share',
|
||||
shortLabel: 'Share',
|
||||
action: 'share',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'stop',
|
||||
label: 'Stop',
|
||||
icon: 'stop',
|
||||
shortLabel: 'Stop',
|
||||
action: 'stop',
|
||||
requiresAuth: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('should filter out auth-required actions when not authenticated', () => {
|
||||
const available = getAvailableActions(actions, { isAuthenticated: false });
|
||||
expect(available.map(a => a.id)).toEqual(['start', 'stop']);
|
||||
});
|
||||
|
||||
it('should include all when authenticated', () => {
|
||||
const available = getAvailableActions(actions, { isAuthenticated: true });
|
||||
expect(available).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should include non-auth actions by default', () => {
|
||||
const available = getAvailableActions(actions, {});
|
||||
expect(available.map(a => a.id)).toEqual(['start', 'stop']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSmartDefault', () => {
|
||||
it('should prefer last_used over others', () => {
|
||||
const candidates: SmartDefault[] = [
|
||||
{ key: 'protocol', value: 'OMAD', source: 'most_common' },
|
||||
{ key: 'protocol', value: '16:8', source: 'last_used' },
|
||||
{ key: 'protocol', value: '18:6', source: 'recommendation' },
|
||||
];
|
||||
const result = pickSmartDefault(candidates);
|
||||
expect(result?.value).toBe('16:8');
|
||||
});
|
||||
|
||||
it('should fall back to most_common', () => {
|
||||
const candidates: SmartDefault[] = [
|
||||
{ key: 'protocol', value: 'OMAD', source: 'most_common' },
|
||||
{ key: 'protocol', value: '18:6', source: 'system' },
|
||||
];
|
||||
const result = pickSmartDefault(candidates);
|
||||
expect(result?.value).toBe('OMAD');
|
||||
});
|
||||
|
||||
it('should return null for empty array', () => {
|
||||
expect(pickSmartDefault([])).toBeNull();
|
||||
});
|
||||
|
||||
it('should return first if no priority match', () => {
|
||||
const candidates: SmartDefault[] = [{ key: 'x', value: 'a', source: 'system' }];
|
||||
const result = pickSmartDefault(candidates);
|
||||
expect(result?.value).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('should export MAX_VISIBLE_ITEMS', () => {
|
||||
expect(MAX_VISIBLE_ITEMS).toBe(3);
|
||||
});
|
||||
|
||||
it('should export MAX_VISIBLE_LIST', () => {
|
||||
expect(MAX_VISIBLE_LIST).toBe(5);
|
||||
});
|
||||
});
|
||||
47
packages/quick-actions/src/client.ts
Normal file
47
packages/quick-actions/src/client.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Progressive disclosure system, smart defaults, quick action definitions.
|
||||
*
|
||||
* Reduces cognitive load by surfacing only relevant UI sections and actions.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js';
|
||||
|
||||
export const MAX_VISIBLE_ITEMS = 3;
|
||||
export const MAX_VISIBLE_LIST = 5;
|
||||
|
||||
export function getVisibleSections(
|
||||
sections: ProgressiveSection[],
|
||||
expandedIds: Set<string>
|
||||
): ProgressiveSection[] {
|
||||
return sections.filter(
|
||||
s => s.defaultExpanded || expandedIds.has(s.id) || s.priority === 'primary'
|
||||
);
|
||||
}
|
||||
|
||||
export function getAvailableActions(
|
||||
actions: QuickAction[],
|
||||
context: { isActive?: boolean; isAuthenticated?: boolean }
|
||||
): QuickAction[] {
|
||||
return actions.filter(a => {
|
||||
if (a.requiresAuth && !context.isAuthenticated) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function pickSmartDefault(candidates: SmartDefault[]): SmartDefault | null {
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const priority: SmartDefault['source'][] = [
|
||||
'last_used',
|
||||
'most_common',
|
||||
'recommendation',
|
||||
'system',
|
||||
];
|
||||
for (const source of priority) {
|
||||
const match = candidates.find(c => c.source === source);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
8
packages/quick-actions/src/index.ts
Normal file
8
packages/quick-actions/src/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export {
|
||||
getVisibleSections,
|
||||
getAvailableActions,
|
||||
pickSmartDefault,
|
||||
MAX_VISIBLE_ITEMS,
|
||||
MAX_VISIBLE_LIST,
|
||||
} from './client.js';
|
||||
export type { QuickAction, ProgressiveSection, SmartDefault } from './types.js';
|
||||
26
packages/quick-actions/src/types.ts
Normal file
26
packages/quick-actions/src/types.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Types for @bytelyst/quick-actions.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
shortLabel: string;
|
||||
action: string;
|
||||
requiresAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressiveSection {
|
||||
id: string;
|
||||
title: string;
|
||||
defaultExpanded: boolean;
|
||||
priority: 'primary' | 'secondary' | 'detail';
|
||||
}
|
||||
|
||||
export interface SmartDefault {
|
||||
key: string;
|
||||
value: unknown;
|
||||
source: 'last_used' | 'most_common' | 'recommendation' | 'system';
|
||||
}
|
||||
10
packages/quick-actions/tsconfig.json
Normal file
10
packages/quick-actions/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/referral-client/package.json
Normal file
21
packages/referral-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/referral-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe referral client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
224
packages/referral-client/src/client.test.ts
Normal file
224
packages/referral-client/src/client.test.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createReferralClient } from './client.js';
|
||||
import type { ReferralDoc } from './types.js';
|
||||
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAccessToken: () => 'test-token',
|
||||
};
|
||||
|
||||
function mockReferral(overrides?: Partial<ReferralDoc>): ReferralDoc {
|
||||
return {
|
||||
id: 'ref-1',
|
||||
productId: 'testapp',
|
||||
referrerId: 'user-1',
|
||||
referrerEmail: 'alice@test.com',
|
||||
referredUserId: null,
|
||||
referredEmail: 'bob@test.com',
|
||||
status: 'pending',
|
||||
referrerRewardTokens: 1000,
|
||||
referredRewardTokens: 500,
|
||||
referrerRewarded: false,
|
||||
referredRewarded: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createReferralClient', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create a referral', async () => {
|
||||
const ref = mockReferral();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(ref),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.createReferral({
|
||||
referrerId: 'user-1',
|
||||
referrerEmail: 'alice@test.com',
|
||||
referredEmail: 'bob@test.com',
|
||||
});
|
||||
|
||||
expect(result).toEqual(ref);
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/referrals',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should list referrals by referrerId', async () => {
|
||||
const data = { referrals: [mockReferral()], count: 1 };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(data),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.listMyReferrals('user-1');
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.referrals).toHaveLength(1);
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/referrals/by-referrer/user-1',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should get referral stats', async () => {
|
||||
const stats = { total: 10, completed: 5, rewarded: 3 };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(stats),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.getReferralStats();
|
||||
expect(result).toEqual(stats);
|
||||
});
|
||||
|
||||
it('should update referral status via PUT', async () => {
|
||||
const updated = mockReferral({ status: 'signed_up' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updated),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.updateReferralStatus('ref-1', 'user-1', 'signed_up');
|
||||
|
||||
expect(result.status).toBe('signed_up');
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/referrals/ref-1',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should get referral by email', async () => {
|
||||
const ref = mockReferral();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(ref),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.getByEmail('bob@test.com');
|
||||
expect(result).toEqual(ref);
|
||||
});
|
||||
|
||||
it('should return null for 404 on getByEmail', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
const result = await client.getByEmail('unknown@test.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should send correct headers', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ total: 0, completed: 0, rewarded: 0 }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
await client.getReferralStats();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const callHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(callHeaders['x-product-id']).toBe('testapp');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer test-token');
|
||||
expect(callHeaders['x-request-id']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw on non-ok response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient(baseConfig);
|
||||
await expect(client.getReferralStats()).rejects.toThrow('getReferralStats failed: 500');
|
||||
});
|
||||
|
||||
it('should build share link', () => {
|
||||
const client = createReferralClient(baseConfig);
|
||||
const link = client.buildShareLink('ABC123');
|
||||
expect(link).toContain('refer/ABC123');
|
||||
expect(link).toContain('product=testapp');
|
||||
});
|
||||
|
||||
it('should build share message', () => {
|
||||
const client = createReferralClient(baseConfig);
|
||||
const msg = client.buildShareMessage('ABC123', 'NomGap');
|
||||
expect(msg).toContain('NomGap');
|
||||
expect(msg).toContain('refer/ABC123');
|
||||
});
|
||||
|
||||
it('should calculate earned days', () => {
|
||||
const client = createReferralClient(baseConfig);
|
||||
expect(client.calculateEarnedDays(3)).toBe(21);
|
||||
expect(client.calculateEarnedDays(0)).toBe(0);
|
||||
expect(client.calculateEarnedDays(2, 14)).toBe(28);
|
||||
});
|
||||
|
||||
it('should use custom default reward tokens', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReferral()),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createReferralClient({
|
||||
...baseConfig,
|
||||
defaultRewardTokens: { referrer: 2000, referred: 1000 },
|
||||
});
|
||||
await client.createReferral({
|
||||
referrerId: 'user-1',
|
||||
referrerEmail: 'alice@test.com',
|
||||
referredEmail: 'bob@test.com',
|
||||
});
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(body.referrerRewardTokens).toBe(2000);
|
||||
expect(body.referredRewardTokens).toBe(1000);
|
||||
});
|
||||
});
|
||||
122
packages/referral-client/src/client.ts
Normal file
122
packages/referral-client/src/client.ts
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Browser/React Native-safe referral client for platform-service.
|
||||
*
|
||||
* Wraps platform-service /referrals/* endpoints.
|
||||
* No Node.js dependencies — uses globalThis.fetch.
|
||||
*/
|
||||
|
||||
import type { ReferralClient, ReferralClientConfig, ReferralDoc } 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 createReferralClient(config: ReferralClientConfig): ReferralClient {
|
||||
const { baseUrl, productId, getAccessToken, defaultRewardTokens } = config;
|
||||
|
||||
const defaultReferrerTokens = defaultRewardTokens?.referrer ?? 1000;
|
||||
const defaultReferredTokens = defaultRewardTokens?.referred ?? 500;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function listMyReferrals(
|
||||
referrerId: string
|
||||
): Promise<{ referrals: ReferralDoc[]; count: number }> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/referrals/by-referrer/${encodeURIComponent(referrerId)}`,
|
||||
{ headers: headers() }
|
||||
);
|
||||
if (!res.ok) throw new Error(`listMyReferrals failed: ${res.status}`);
|
||||
const data = (await res.json()) as { referrals: ReferralDoc[]; count: number };
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getReferralStats(): Promise<{
|
||||
total: number;
|
||||
completed: number;
|
||||
rewarded: number;
|
||||
}> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/referrals/stats`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`getReferralStats failed: ${res.status}`);
|
||||
return (await res.json()) as { total: number; completed: number; rewarded: number };
|
||||
}
|
||||
|
||||
async function createReferral(input: {
|
||||
referrerId: string;
|
||||
referrerEmail: string;
|
||||
referredEmail: string;
|
||||
}): Promise<ReferralDoc> {
|
||||
const body = {
|
||||
...input,
|
||||
productId,
|
||||
referrerRewardTokens: defaultReferrerTokens,
|
||||
referredRewardTokens: defaultReferredTokens,
|
||||
};
|
||||
const res = await globalThis.fetch(`${baseUrl}/referrals`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`createReferral failed: ${res.status}`);
|
||||
return (await res.json()) as ReferralDoc;
|
||||
}
|
||||
|
||||
async function updateReferralStatus(
|
||||
id: string,
|
||||
referrerId: string,
|
||||
status: ReferralDoc['status']
|
||||
): Promise<ReferralDoc> {
|
||||
const res = await globalThis.fetch(`${baseUrl}/referrals/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ referrerId, status }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`updateReferralStatus failed: ${res.status}`);
|
||||
return (await res.json()) as ReferralDoc;
|
||||
}
|
||||
|
||||
async function getByEmail(email: string): Promise<ReferralDoc | null> {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/referrals/by-email/${encodeURIComponent(email)}`,
|
||||
{ headers: headers() }
|
||||
);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`getByEmail failed: ${res.status}`);
|
||||
return (await res.json()) as ReferralDoc;
|
||||
}
|
||||
|
||||
function buildShareLink(code: string): string {
|
||||
return `https://bytelyst.com/refer/${encodeURIComponent(code)}?product=${encodeURIComponent(productId)}`;
|
||||
}
|
||||
|
||||
function buildShareMessage(code: string, productName: string): string {
|
||||
const link = buildShareLink(code);
|
||||
return `Try ${productName}! Use my referral link to get started: ${link}`;
|
||||
}
|
||||
|
||||
function calculateEarnedDays(conversions: number, daysPerReferral = 7): number {
|
||||
return Math.max(0, conversions * daysPerReferral);
|
||||
}
|
||||
|
||||
return {
|
||||
listMyReferrals,
|
||||
getReferralStats,
|
||||
createReferral,
|
||||
updateReferralStatus,
|
||||
getByEmail,
|
||||
buildShareLink,
|
||||
buildShareMessage,
|
||||
calculateEarnedDays,
|
||||
};
|
||||
}
|
||||
2
packages/referral-client/src/index.ts
Normal file
2
packages/referral-client/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { createReferralClient } from './client.js';
|
||||
export type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js';
|
||||
55
packages/referral-client/src/types.ts
Normal file
55
packages/referral-client/src/types.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Types for @bytelyst/referral-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface ReferralDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
referrerId: string;
|
||||
referrerEmail: string;
|
||||
referredUserId: string | null;
|
||||
referredEmail: string;
|
||||
status: 'pending' | 'signed_up' | 'subscribed' | 'rewarded';
|
||||
referrerRewardTokens: number;
|
||||
referredRewardTokens: number;
|
||||
referrerRewarded: boolean;
|
||||
referredRewarded: boolean;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ReferralClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header on every request. */
|
||||
productId: string;
|
||||
|
||||
/** Returns a JWT access token, or null if not authenticated. */
|
||||
getAccessToken: () => string | null;
|
||||
|
||||
/** Default reward tokens applied when creating referrals. */
|
||||
defaultRewardTokens?: { referrer: number; referred: number };
|
||||
}
|
||||
|
||||
export interface ReferralClient {
|
||||
listMyReferrals(referrerId: string): Promise<{ referrals: ReferralDoc[]; count: number }>;
|
||||
getReferralStats(): Promise<{ total: number; completed: number; rewarded: number }>;
|
||||
createReferral(input: {
|
||||
referrerId: string;
|
||||
referrerEmail: string;
|
||||
referredEmail: string;
|
||||
}): Promise<ReferralDoc>;
|
||||
updateReferralStatus(
|
||||
id: string,
|
||||
referrerId: string,
|
||||
status: ReferralDoc['status']
|
||||
): Promise<ReferralDoc>;
|
||||
getByEmail(email: string): Promise<ReferralDoc | null>;
|
||||
|
||||
// Client-side helpers (pure TS, no network)
|
||||
buildShareLink(code: string): string;
|
||||
buildShareMessage(code: string, productName: string): string;
|
||||
calculateEarnedDays(conversions: number, daysPerReferral?: number): number;
|
||||
}
|
||||
10
packages/referral-client/tsconfig.json
Normal file
10
packages/referral-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/subscription-client/package.json
Normal file
21
packages/subscription-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/subscription-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe subscription and plan client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
283
packages/subscription-client/src/client.test.ts
Normal file
283
packages/subscription-client/src/client.test.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createSubscriptionClient } from './client.js';
|
||||
import type { SubscriptionDoc, PlanConfig } from './types.js';
|
||||
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
userId: 'user-1',
|
||||
getAccessToken: () => 'test-token',
|
||||
};
|
||||
|
||||
function mockSub(overrides?: Partial<SubscriptionDoc>): SubscriptionDoc {
|
||||
return {
|
||||
id: 'sub-1',
|
||||
productId: 'testapp',
|
||||
userId: 'user-1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
currentPeriodStart: '2026-01-01T00:00:00Z',
|
||||
currentPeriodEnd: '2026-12-31T23:59:59Z',
|
||||
cancelAtPeriodEnd: false,
|
||||
monthlyPrice: 999,
|
||||
tokensIncluded: 10000,
|
||||
tokensUsed: 500,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockPlan(overrides?: Partial<PlanConfig>): PlanConfig {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
productId: 'testapp',
|
||||
name: 'pro',
|
||||
displayName: 'Pro',
|
||||
price: 999,
|
||||
tokens: 10000,
|
||||
words: 0,
|
||||
dictations: 0,
|
||||
features: ['ai_coaching', 'advanced_stats'],
|
||||
active: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createSubscriptionClient', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should get subscription by userId', async () => {
|
||||
const sub = mockSub();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sub),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
const result = await client.getMySubscription();
|
||||
|
||||
expect(result).toEqual(sub);
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/subscriptions/user-1',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for 404 on getMySubscription', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
const result = await client.getMySubscription();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should unwrap .plans from getPlans response', async () => {
|
||||
const plans = [mockPlan(), mockPlan({ name: 'free', displayName: 'Free', features: [] })];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ plans }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
const result = await client.getPlans();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('pro');
|
||||
});
|
||||
|
||||
it('should start a trial', async () => {
|
||||
const sub = mockSub({ status: 'trialing' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sub),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
const result = await client.startTrial();
|
||||
expect(result.status).toBe('trialing');
|
||||
});
|
||||
|
||||
it('should cancel subscription', async () => {
|
||||
const sub = mockSub({ cancelAtPeriodEnd: true });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sub),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
const result = await client.cancelSubscription();
|
||||
expect(result.cancelAtPeriodEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('should report isPro correctly from cache', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub({ plan: 'pro', status: 'active' })),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
expect(client.isPro()).toBe(false); // no cache yet
|
||||
|
||||
await client.getMySubscription();
|
||||
expect(client.isPro()).toBe(true);
|
||||
});
|
||||
|
||||
it('should report isPro false for free plan', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub({ plan: 'free', status: 'active' })),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await client.getMySubscription();
|
||||
expect(client.isPro()).toBe(false);
|
||||
});
|
||||
|
||||
it('should check hasFeature from cached plans', async () => {
|
||||
let callIndex = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub({ plan: 'pro' })),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
plans: [mockPlan({ name: 'pro', features: ['ai_coaching', 'advanced_stats'] })],
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await client.refresh();
|
||||
|
||||
expect(client.hasFeature('ai_coaching')).toBe(true);
|
||||
expect(client.hasFeature('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should report isTrialing correctly', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub({ status: 'trialing' })),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await client.getMySubscription();
|
||||
expect(client.isTrialing()).toBe(true);
|
||||
});
|
||||
|
||||
it('should calculate daysRemaining', async () => {
|
||||
const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub({ currentPeriodEnd: futureDate })),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await client.getMySubscription();
|
||||
const days = client.daysRemaining();
|
||||
expect(days).toBeGreaterThanOrEqual(10);
|
||||
expect(days).toBeLessThanOrEqual(11);
|
||||
});
|
||||
|
||||
it('should persist and restore from storage', async () => {
|
||||
const store: Record<string, string> = {};
|
||||
const storage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockSub()),
|
||||
})
|
||||
);
|
||||
|
||||
const client1 = createSubscriptionClient({ ...baseConfig, storage });
|
||||
await client1.getMySubscription();
|
||||
|
||||
expect(store['testapp-subscription']).toBeDefined();
|
||||
|
||||
// Create new client — should restore from storage
|
||||
const client2 = createSubscriptionClient({ ...baseConfig, storage });
|
||||
expect(client2.getCachedSubscription()).not.toBeNull();
|
||||
expect(client2.isPro()).toBe(true);
|
||||
});
|
||||
|
||||
it('should send correct headers', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ plans: [] }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await client.getPlans();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const callHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(callHeaders['x-product-id']).toBe('testapp');
|
||||
expect(callHeaders['Authorization']).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('should throw on non-ok response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
|
||||
const client = createSubscriptionClient(baseConfig);
|
||||
await expect(client.getPlans()).rejects.toThrow('getPlans failed: 500');
|
||||
});
|
||||
});
|
||||
193
packages/subscription-client/src/client.ts
Normal file
193
packages/subscription-client/src/client.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
7
packages/subscription-client/src/index.ts
Normal file
7
packages/subscription-client/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { createSubscriptionClient } from './client.js';
|
||||
export type {
|
||||
SubscriptionClient,
|
||||
SubscriptionClientConfig,
|
||||
SubscriptionDoc,
|
||||
PlanConfig,
|
||||
} from './types.js';
|
||||
76
packages/subscription-client/src/types.ts
Normal file
76
packages/subscription-client/src/types.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Types for @bytelyst/subscription-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface SubscriptionDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
plan: 'free' | 'pro' | 'enterprise';
|
||||
status: 'active' | 'cancelled' | 'past_due' | 'trialing';
|
||||
currentPeriodStart: string;
|
||||
currentPeriodEnd: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
monthlyPrice: number;
|
||||
tokensIncluded: number;
|
||||
tokensUsed: number;
|
||||
stripeCustomerId?: string;
|
||||
stripeSubscriptionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlanConfig {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
price: number;
|
||||
tokens: number;
|
||||
words: number;
|
||||
dictations: number;
|
||||
features: string[];
|
||||
stripePriceId?: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header on every request. */
|
||||
productId: string;
|
||||
|
||||
/** User ID — subscription routes are keyed by userId, not id. */
|
||||
userId: string;
|
||||
|
||||
/** Returns a JWT access token, or null if not authenticated. */
|
||||
getAccessToken: () => string | null;
|
||||
|
||||
/** Optional persistent storage adapter for cache. */
|
||||
storage?: {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscriptionClient {
|
||||
// Server-authoritative checks
|
||||
getMySubscription(): Promise<SubscriptionDoc | null>;
|
||||
getPlans(): Promise<PlanConfig[]>;
|
||||
startTrial(planName?: string): Promise<SubscriptionDoc>;
|
||||
cancelSubscription(): Promise<SubscriptionDoc>;
|
||||
updateSubscription(updates: Partial<SubscriptionDoc>): Promise<SubscriptionDoc>;
|
||||
|
||||
// Client-side helpers (cached, offline-safe)
|
||||
isPro(): boolean;
|
||||
isTrialing(): boolean;
|
||||
hasFeature(feature: string): boolean;
|
||||
daysRemaining(): number | null;
|
||||
getCachedSubscription(): SubscriptionDoc | null;
|
||||
getCachedPlans(): PlanConfig[];
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
10
packages/subscription-client/tsconfig.json
Normal file
10
packages/subscription-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/time-references/package.json
Normal file
21
packages/time-references/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/time-references",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Familiar duration references for time-blindness aids",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
107
packages/time-references/src/client.test.ts
Normal file
107
packages/time-references/src/client.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
getTimeReference,
|
||||
getEpisodeComparison,
|
||||
getEncouragingMessage,
|
||||
registerReferences,
|
||||
clearCustomReferences,
|
||||
} from './client.js';
|
||||
|
||||
describe('getTimeReference', () => {
|
||||
it('should return a reference for short duration', () => {
|
||||
const ref = getTimeReference(0.1);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
expect(ref.emoji.length).toBeGreaterThan(0);
|
||||
expect(['media', 'activity', 'travel', 'nature']).toContain(ref.category);
|
||||
});
|
||||
|
||||
it('should return a reference for medium duration', () => {
|
||||
const ref = getTimeReference(1.5);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return a reference for long duration', () => {
|
||||
const ref = getTimeReference(16);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return a reference for very long duration', () => {
|
||||
const ref = getTimeReference(72);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle zero hours', () => {
|
||||
const ref = getTimeReference(0);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle negative as zero', () => {
|
||||
const ref = getTimeReference(-5);
|
||||
expect(ref.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEpisodeComparison', () => {
|
||||
it('should return episode count for default show', () => {
|
||||
const result = getEpisodeComparison(1);
|
||||
expect(result).toContain('The Office');
|
||||
expect(result).toContain('episodes');
|
||||
});
|
||||
|
||||
it('should return custom show name', () => {
|
||||
const result = getEpisodeComparison(2, 'Friends', 25);
|
||||
expect(result).toContain('Friends');
|
||||
});
|
||||
|
||||
it('should handle less than one episode', () => {
|
||||
const result = getEpisodeComparison(0.1);
|
||||
expect(result).toContain('Less than one episode');
|
||||
});
|
||||
|
||||
it('should handle exactly one episode', () => {
|
||||
const result = getEpisodeComparison(22 / 60);
|
||||
expect(result).toContain('1 episode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEncouragingMessage', () => {
|
||||
it('should return message for each time bracket', () => {
|
||||
expect(getEncouragingMessage(0.5).length).toBeGreaterThan(0);
|
||||
expect(getEncouragingMessage(2).length).toBeGreaterThan(0);
|
||||
expect(getEncouragingMessage(6).length).toBeGreaterThan(0);
|
||||
expect(getEncouragingMessage(12).length).toBeGreaterThan(0);
|
||||
expect(getEncouragingMessage(20).length).toBeGreaterThan(0);
|
||||
expect(getEncouragingMessage(30).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerReferences', () => {
|
||||
afterEach(() => {
|
||||
clearCustomReferences();
|
||||
});
|
||||
|
||||
it('should allow custom references', () => {
|
||||
registerReferences([
|
||||
{
|
||||
minHours: 0,
|
||||
maxHours: 0.1,
|
||||
references: [{ text: 'Custom micro reference', emoji: '⚡', category: 'activity' }],
|
||||
},
|
||||
]);
|
||||
const ref = getTimeReference(0.05);
|
||||
expect(ref.text).toBe('Custom micro reference');
|
||||
});
|
||||
|
||||
it('should clear custom references', () => {
|
||||
registerReferences([
|
||||
{
|
||||
minHours: 0,
|
||||
maxHours: 0.1,
|
||||
references: [{ text: 'Will be cleared', emoji: '🧹', category: 'activity' }],
|
||||
},
|
||||
]);
|
||||
clearCustomReferences();
|
||||
const ref = getTimeReference(0.05);
|
||||
expect(ref.text).not.toBe('Will be cleared');
|
||||
});
|
||||
});
|
||||
159
packages/time-references/src/client.ts
Normal file
159
packages/time-references/src/client.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Familiar duration references for time-blindness aids.
|
||||
*
|
||||
* "About as long as a movie", "3 episodes of The Office".
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
import type { TimeRangeEntry, TimeReference } from './types.js';
|
||||
|
||||
const DEFAULT_DATABASE: TimeRangeEntry[] = [
|
||||
{
|
||||
minHours: 0,
|
||||
maxHours: 0.25,
|
||||
references: [
|
||||
{ text: 'About as long as a coffee break', emoji: '☕', category: 'activity' },
|
||||
{ text: 'Like listening to a few songs', emoji: '🎵', category: 'media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 0.25,
|
||||
maxHours: 0.5,
|
||||
references: [
|
||||
{ text: 'About as long as a TV episode', emoji: '📺', category: 'media' },
|
||||
{ text: 'Like a short walk around the block', emoji: '🚶', category: 'activity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 0.5,
|
||||
maxHours: 1,
|
||||
references: [
|
||||
{ text: 'About as long as a yoga class', emoji: '🧘', category: 'activity' },
|
||||
{ text: 'Like watching 2 episodes of The Office', emoji: '📺', category: 'media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 1,
|
||||
maxHours: 2,
|
||||
references: [
|
||||
{ text: 'About as long as a movie', emoji: '🎬', category: 'media' },
|
||||
{ text: 'Like a nice bike ride', emoji: '🚴', category: 'activity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 2,
|
||||
maxHours: 4,
|
||||
references: [
|
||||
{ text: 'About as long as a road trip to the next city', emoji: '🚗', category: 'travel' },
|
||||
{ text: 'Like binge-watching a short series', emoji: '📺', category: 'media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 4,
|
||||
maxHours: 8,
|
||||
references: [
|
||||
{ text: 'About as long as a work day', emoji: '💼', category: 'activity' },
|
||||
{ text: 'Like a full night of sleep', emoji: '😴', category: 'nature' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 8,
|
||||
maxHours: 12,
|
||||
references: [
|
||||
{ text: 'About as long as a cross-country flight', emoji: '✈️', category: 'travel' },
|
||||
{ text: 'Like sunrise to sunset in winter', emoji: '🌅', category: 'nature' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 12,
|
||||
maxHours: 16,
|
||||
references: [
|
||||
{ text: 'About as long as daylight hours in summer', emoji: '☀️', category: 'nature' },
|
||||
{
|
||||
text: 'Like watching the entire Lord of the Rings trilogy (extended)',
|
||||
emoji: '🧙',
|
||||
category: 'media',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 16,
|
||||
maxHours: 24,
|
||||
references: [
|
||||
{ text: 'Almost a full day — impressive!', emoji: '🌍', category: 'nature' },
|
||||
{ text: 'Like a full day of hiking', emoji: '🥾', category: 'activity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 24,
|
||||
maxHours: 48,
|
||||
references: [
|
||||
{ text: 'More than a full day!', emoji: '🏆', category: 'nature' },
|
||||
{ text: 'Like a weekend camping trip', emoji: '⛺', category: 'activity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
minHours: 48,
|
||||
maxHours: Infinity,
|
||||
references: [
|
||||
{ text: 'An extraordinary duration — you are incredible!', emoji: '🌟', category: 'nature' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FALLBACK: TimeReference = {
|
||||
text: 'A meaningful amount of time',
|
||||
emoji: '⏳',
|
||||
category: 'nature',
|
||||
};
|
||||
|
||||
const customRegistry: TimeRangeEntry[] = [];
|
||||
|
||||
function findReferences(hours: number, custom: TimeRangeEntry[]): TimeReference[] {
|
||||
for (const entry of custom) {
|
||||
if (hours >= entry.minHours && hours < entry.maxHours) {
|
||||
return entry.references;
|
||||
}
|
||||
}
|
||||
for (const entry of DEFAULT_DATABASE) {
|
||||
if (hours >= entry.minHours && hours < entry.maxHours) {
|
||||
return entry.references;
|
||||
}
|
||||
}
|
||||
return [FALLBACK];
|
||||
}
|
||||
|
||||
export function getTimeReference(elapsedHours: number): TimeReference {
|
||||
const refs = findReferences(Math.max(0, elapsedHours), customRegistry);
|
||||
const index = Math.floor(Math.random() * refs.length);
|
||||
return refs[index];
|
||||
}
|
||||
|
||||
export function getEpisodeComparison(
|
||||
elapsedHours: number,
|
||||
showName = 'The Office',
|
||||
episodeMins = 22
|
||||
): string {
|
||||
const totalMins = elapsedHours * 60;
|
||||
const episodes = Math.round(totalMins / episodeMins);
|
||||
if (episodes <= 0) return `Less than one episode of ${showName}`;
|
||||
if (episodes === 1) return `About 1 episode of ${showName}`;
|
||||
return `About ${episodes} episodes of ${showName}`;
|
||||
}
|
||||
|
||||
export function getEncouragingMessage(elapsedHours: number): string {
|
||||
if (elapsedHours < 1) return 'Every minute counts — great start!';
|
||||
if (elapsedHours < 4) return 'You are building momentum — keep going!';
|
||||
if (elapsedHours < 8) return 'Impressive dedication — halfway through the day!';
|
||||
if (elapsedHours < 16) return 'Amazing endurance — you are a champion!';
|
||||
if (elapsedHours < 24) return 'Nearly a full day — extraordinary commitment!';
|
||||
return 'Beyond a full day — you are truly remarkable!';
|
||||
}
|
||||
|
||||
export function registerReferences(entries: TimeRangeEntry[]): void {
|
||||
customRegistry.push(...entries);
|
||||
}
|
||||
|
||||
export function clearCustomReferences(): void {
|
||||
customRegistry.length = 0;
|
||||
}
|
||||
8
packages/time-references/src/index.ts
Normal file
8
packages/time-references/src/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export {
|
||||
getTimeReference,
|
||||
getEpisodeComparison,
|
||||
getEncouragingMessage,
|
||||
registerReferences,
|
||||
clearCustomReferences,
|
||||
} from './client.js';
|
||||
export type { TimeReference, TimeRangeEntry } from './types.js';
|
||||
16
packages/time-references/src/types.ts
Normal file
16
packages/time-references/src/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Types for @bytelyst/time-references.
|
||||
* Pure client-side TS — no backend dependency.
|
||||
*/
|
||||
|
||||
export interface TimeReference {
|
||||
text: string;
|
||||
emoji: string;
|
||||
category: 'media' | 'activity' | 'travel' | 'nature';
|
||||
}
|
||||
|
||||
export interface TimeRangeEntry {
|
||||
minHours: number;
|
||||
maxHours: number;
|
||||
references: TimeReference[];
|
||||
}
|
||||
10
packages/time-references/tsconfig.json
Normal file
10
packages/time-references/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -305,6 +305,8 @@ importers:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/accessibility: {}
|
||||
|
||||
packages/api-client: {}
|
||||
|
||||
packages/auth:
|
||||
@ -352,6 +354,8 @@ importers:
|
||||
|
||||
packages/broadcast-client: {}
|
||||
|
||||
packages/celebrations: {}
|
||||
|
||||
packages/config:
|
||||
dependencies:
|
||||
zod:
|
||||
@ -496,6 +500,8 @@ importers:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/gentle-notifications: {}
|
||||
|
||||
packages/kill-switch-client: {}
|
||||
|
||||
packages/llm:
|
||||
@ -515,10 +521,14 @@ importers:
|
||||
|
||||
packages/logger: {}
|
||||
|
||||
packages/marketplace-client: {}
|
||||
|
||||
packages/monitoring: {}
|
||||
|
||||
packages/offline-queue: {}
|
||||
|
||||
packages/org-client: {}
|
||||
|
||||
packages/platform-client: {}
|
||||
|
||||
packages/push:
|
||||
@ -536,6 +546,8 @@ importers:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/quick-actions: {}
|
||||
|
||||
packages/react-auth:
|
||||
dependencies:
|
||||
'@bytelyst/api-client':
|
||||
@ -583,6 +595,8 @@ importers:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/referral-client: {}
|
||||
|
||||
packages/speech:
|
||||
devDependencies:
|
||||
typescript:
|
||||
@ -602,6 +616,8 @@ importers:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/subscription-client: {}
|
||||
|
||||
packages/survey-client: {}
|
||||
|
||||
packages/sync:
|
||||
@ -638,6 +654,8 @@ importers:
|
||||
specifier: ^5.2.1
|
||||
version: 5.7.4
|
||||
|
||||
packages/time-references: {}
|
||||
|
||||
packages/webhook-dispatch: {}
|
||||
|
||||
services/extraction-service:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user