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:
saravanakumardb1 2026-03-19 13:10:09 -07:00
parent c87a8e9ef1
commit be03efa111
55 changed files with 4051 additions and 0 deletions

View 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"
}
}

View 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);
});
});

View 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];
}

View 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';

View 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;
};
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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);
});
});

View 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,
};
}

View File

@ -0,0 +1,7 @@
export { createCelebrationEngine } from './client.js';
export type {
Celebration,
CelebrationConfig,
CelebrationEngine,
CelebrationTrigger,
} from './types.js';

View 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;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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');
});
});

View 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,
};
}

View File

@ -0,0 +1,2 @@
export { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js';
export type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js';

View 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;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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();
});
});

View 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,
};
}

View File

@ -0,0 +1,9 @@
export { createMarketplaceClient } from './client.js';
export type {
MarketplaceClient,
MarketplaceClientConfig,
MarketplaceListingDoc,
MarketplaceReviewDoc,
MarketplaceInstallDoc,
CreateListingInput,
} from './types.js';

View 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>;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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();
});
});

View 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,
};
}

View File

@ -0,0 +1,9 @@
export { createOrgClient } from './client.js';
export type {
OrgClient,
OrgClientConfig,
OrganizationDoc,
WorkspaceDoc,
MembershipDoc,
LicenseDoc,
} from './types.js';

View 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>;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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);
});
});

View 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];
}

View 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';

View 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';
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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);
});
});

View 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,
};
}

View File

@ -0,0 +1,2 @@
export { createReferralClient } from './client.js';
export type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js';

View 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;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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');
});
});

View 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,
};
}

View File

@ -0,0 +1,7 @@
export { createSubscriptionClient } from './client.js';
export type {
SubscriptionClient,
SubscriptionClientConfig,
SubscriptionDoc,
PlanConfig,
} from './types.js';

View 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>;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View 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"
}
}

View 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');
});
});

View 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;
}

View File

@ -0,0 +1,8 @@
export {
getTimeReference,
getEpisodeComparison,
getEncouragingMessage,
registerReferences,
clearCustomReferences,
} from './client.js';
export type { TimeReference, TimeRangeEntry } from './types.js';

View 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[];
}

View 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
View File

@ -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: