From be03efa111d313d260e5bbed563b559c461d71f6 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 13:10:09 -0700 Subject: [PATCH] feat(shared-packages): add 9 @bytelyst/* client packages with 100% API coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/accessibility/package.json | 21 ++ packages/accessibility/src/client.test.ts | 154 ++++++++++ packages/accessibility/src/client.ts | 172 +++++++++++ packages/accessibility/src/index.ts | 15 + packages/accessibility/src/types.ts | 33 ++ packages/accessibility/tsconfig.json | 10 + packages/celebrations/package.json | 21 ++ packages/celebrations/src/client.test.ts | 112 +++++++ packages/celebrations/src/client.ts | 225 ++++++++++++++ packages/celebrations/src/index.ts | 7 + packages/celebrations/src/types.ts | 41 +++ packages/celebrations/tsconfig.json | 10 + packages/gentle-notifications/package.json | 21 ++ .../gentle-notifications/src/client.test.ts | 97 ++++++ packages/gentle-notifications/src/client.ts | 157 ++++++++++ packages/gentle-notifications/src/index.ts | 2 + packages/gentle-notifications/src/types.ts | 29 ++ packages/gentle-notifications/tsconfig.json | 10 + packages/marketplace-client/package.json | 21 ++ .../marketplace-client/src/client.test.ts | 282 +++++++++++++++++ packages/marketplace-client/src/client.ts | 219 +++++++++++++ packages/marketplace-client/src/index.ts | 9 + packages/marketplace-client/src/types.ts | 115 +++++++ packages/marketplace-client/tsconfig.json | 10 + packages/org-client/package.json | 21 ++ packages/org-client/src/client.test.ts | 289 ++++++++++++++++++ packages/org-client/src/client.ts | 224 ++++++++++++++ packages/org-client/src/index.ts | 9 + packages/org-client/src/types.ts | 113 +++++++ packages/org-client/tsconfig.json | 10 + packages/quick-actions/package.json | 21 ++ packages/quick-actions/src/client.test.ts | 117 +++++++ packages/quick-actions/src/client.ts | 47 +++ packages/quick-actions/src/index.ts | 8 + packages/quick-actions/src/types.ts | 26 ++ packages/quick-actions/tsconfig.json | 10 + packages/referral-client/package.json | 21 ++ packages/referral-client/src/client.test.ts | 224 ++++++++++++++ packages/referral-client/src/client.ts | 122 ++++++++ packages/referral-client/src/index.ts | 2 + packages/referral-client/src/types.ts | 55 ++++ packages/referral-client/tsconfig.json | 10 + packages/subscription-client/package.json | 21 ++ .../subscription-client/src/client.test.ts | 283 +++++++++++++++++ packages/subscription-client/src/client.ts | 193 ++++++++++++ packages/subscription-client/src/index.ts | 7 + packages/subscription-client/src/types.ts | 76 +++++ packages/subscription-client/tsconfig.json | 10 + packages/time-references/package.json | 21 ++ packages/time-references/src/client.test.ts | 107 +++++++ packages/time-references/src/client.ts | 159 ++++++++++ packages/time-references/src/index.ts | 8 + packages/time-references/src/types.ts | 16 + packages/time-references/tsconfig.json | 10 + pnpm-lock.yaml | 18 ++ 55 files changed, 4051 insertions(+) create mode 100644 packages/accessibility/package.json create mode 100644 packages/accessibility/src/client.test.ts create mode 100644 packages/accessibility/src/client.ts create mode 100644 packages/accessibility/src/index.ts create mode 100644 packages/accessibility/src/types.ts create mode 100644 packages/accessibility/tsconfig.json create mode 100644 packages/celebrations/package.json create mode 100644 packages/celebrations/src/client.test.ts create mode 100644 packages/celebrations/src/client.ts create mode 100644 packages/celebrations/src/index.ts create mode 100644 packages/celebrations/src/types.ts create mode 100644 packages/celebrations/tsconfig.json create mode 100644 packages/gentle-notifications/package.json create mode 100644 packages/gentle-notifications/src/client.test.ts create mode 100644 packages/gentle-notifications/src/client.ts create mode 100644 packages/gentle-notifications/src/index.ts create mode 100644 packages/gentle-notifications/src/types.ts create mode 100644 packages/gentle-notifications/tsconfig.json create mode 100644 packages/marketplace-client/package.json create mode 100644 packages/marketplace-client/src/client.test.ts create mode 100644 packages/marketplace-client/src/client.ts create mode 100644 packages/marketplace-client/src/index.ts create mode 100644 packages/marketplace-client/src/types.ts create mode 100644 packages/marketplace-client/tsconfig.json create mode 100644 packages/org-client/package.json create mode 100644 packages/org-client/src/client.test.ts create mode 100644 packages/org-client/src/client.ts create mode 100644 packages/org-client/src/index.ts create mode 100644 packages/org-client/src/types.ts create mode 100644 packages/org-client/tsconfig.json create mode 100644 packages/quick-actions/package.json create mode 100644 packages/quick-actions/src/client.test.ts create mode 100644 packages/quick-actions/src/client.ts create mode 100644 packages/quick-actions/src/index.ts create mode 100644 packages/quick-actions/src/types.ts create mode 100644 packages/quick-actions/tsconfig.json create mode 100644 packages/referral-client/package.json create mode 100644 packages/referral-client/src/client.test.ts create mode 100644 packages/referral-client/src/client.ts create mode 100644 packages/referral-client/src/index.ts create mode 100644 packages/referral-client/src/types.ts create mode 100644 packages/referral-client/tsconfig.json create mode 100644 packages/subscription-client/package.json create mode 100644 packages/subscription-client/src/client.test.ts create mode 100644 packages/subscription-client/src/client.ts create mode 100644 packages/subscription-client/src/index.ts create mode 100644 packages/subscription-client/src/types.ts create mode 100644 packages/subscription-client/tsconfig.json create mode 100644 packages/time-references/package.json create mode 100644 packages/time-references/src/client.test.ts create mode 100644 packages/time-references/src/client.ts create mode 100644 packages/time-references/src/index.ts create mode 100644 packages/time-references/src/types.ts create mode 100644 packages/time-references/tsconfig.json diff --git a/packages/accessibility/package.json b/packages/accessibility/package.json new file mode 100644 index 00000000..9b6c06b9 --- /dev/null +++ b/packages/accessibility/package.json @@ -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" + } +} diff --git a/packages/accessibility/src/client.test.ts b/packages/accessibility/src/client.test.ts new file mode 100644 index 00000000..068ade6f --- /dev/null +++ b/packages/accessibility/src/client.test.ts @@ -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); + }); +}); diff --git a/packages/accessibility/src/client.ts b/packages/accessibility/src/client.ts new file mode 100644 index 00000000..8e61cef0 --- /dev/null +++ b/packages/accessibility/src/client.ts @@ -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]; +} diff --git a/packages/accessibility/src/index.ts b/packages/accessibility/src/index.ts new file mode 100644 index 00000000..0697f375 --- /dev/null +++ b/packages/accessibility/src/index.ts @@ -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'; diff --git a/packages/accessibility/src/types.ts b/packages/accessibility/src/types.ts new file mode 100644 index 00000000..af0f31d1 --- /dev/null +++ b/packages/accessibility/src/types.ts @@ -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; + }; +} diff --git a/packages/accessibility/tsconfig.json b/packages/accessibility/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/accessibility/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/celebrations/package.json b/packages/celebrations/package.json new file mode 100644 index 00000000..14997169 --- /dev/null +++ b/packages/celebrations/package.json @@ -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" + } +} diff --git a/packages/celebrations/src/client.test.ts b/packages/celebrations/src/client.test.ts new file mode 100644 index 00000000..5bf655e7 --- /dev/null +++ b/packages/celebrations/src/client.test.ts @@ -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(); + + 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); + }); +}); diff --git a/packages/celebrations/src/client.ts b/packages/celebrations/src/client.ts new file mode 100644 index 00000000..600eb151 --- /dev/null +++ b/packages/celebrations/src/client.ts @@ -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 = { + 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 + ): 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, + }; +} diff --git a/packages/celebrations/src/index.ts b/packages/celebrations/src/index.ts new file mode 100644 index 00000000..20b3d0b3 --- /dev/null +++ b/packages/celebrations/src/index.ts @@ -0,0 +1,7 @@ +export { createCelebrationEngine } from './client.js'; +export type { + Celebration, + CelebrationConfig, + CelebrationEngine, + CelebrationTrigger, +} from './types.js'; diff --git a/packages/celebrations/src/types.ts b/packages/celebrations/src/types.ts new file mode 100644 index 00000000..3d1463b3 --- /dev/null +++ b/packages/celebrations/src/types.ts @@ -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; +} + +export interface CelebrationEngine { + getCelebration(trigger: CelebrationTrigger | string): Celebration; + getTimedCelebrations(elapsedMs: number, targetMs: number, shownIds: Set): Celebration[]; + isPersonalBest(current: number, previous: number): boolean; + getPositiveMessage(progressPercent: number): string; + getPositiveIncompleteMessage(progressPercent: number): string; +} diff --git a/packages/celebrations/tsconfig.json b/packages/celebrations/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/celebrations/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/gentle-notifications/package.json b/packages/gentle-notifications/package.json new file mode 100644 index 00000000..7b00fa02 --- /dev/null +++ b/packages/gentle-notifications/package.json @@ -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" + } +} diff --git a/packages/gentle-notifications/src/client.test.ts b/packages/gentle-notifications/src/client.test.ts new file mode 100644 index 00000000..22db4ce9 --- /dev/null +++ b/packages/gentle-notifications/src/client.test.ts @@ -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'); + }); +}); diff --git a/packages/gentle-notifications/src/client.ts b/packages/gentle-notifications/src/client.ts new file mode 100644 index 00000000..c4444da0 --- /dev/null +++ b/packages/gentle-notifications/src/client.ts @@ -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 = { + 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 +): GentleNotificationEngine { + const messagePools: Record = { ...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, + }; +} diff --git a/packages/gentle-notifications/src/index.ts b/packages/gentle-notifications/src/index.ts new file mode 100644 index 00000000..f8b0068d --- /dev/null +++ b/packages/gentle-notifications/src/index.ts @@ -0,0 +1,2 @@ +export { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js'; +export type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js'; diff --git a/packages/gentle-notifications/src/types.ts b/packages/gentle-notifications/src/types.ts new file mode 100644 index 00000000..7cdc031c --- /dev/null +++ b/packages/gentle-notifications/src/types.ts @@ -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; +} diff --git a/packages/gentle-notifications/tsconfig.json b/packages/gentle-notifications/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/gentle-notifications/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/marketplace-client/package.json b/packages/marketplace-client/package.json new file mode 100644 index 00000000..5a4f2ecc --- /dev/null +++ b/packages/marketplace-client/package.json @@ -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" + } +} diff --git a/packages/marketplace-client/src/client.test.ts b/packages/marketplace-client/src/client.test.ts new file mode 100644 index 00000000..b1b80efd --- /dev/null +++ b/packages/marketplace-client/src/client.test.ts @@ -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 { + 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 { + 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 { + 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; + 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; + 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; + 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; + 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; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer test-token'); + expect(callHeaders['x-request-id']).toBeDefined(); + }); +}); diff --git a/packages/marketplace-client/src/client.ts b/packages/marketplace-client/src/client.ts new file mode 100644 index 00000000..bf3a63e8 --- /dev/null +++ b/packages/marketplace-client/src/client.ts @@ -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 { + const h: Record = { + '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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/marketplace-client/src/index.ts b/packages/marketplace-client/src/index.ts new file mode 100644 index 00000000..ad61e175 --- /dev/null +++ b/packages/marketplace-client/src/index.ts @@ -0,0 +1,9 @@ +export { createMarketplaceClient } from './client.js'; +export type { + MarketplaceClient, + MarketplaceClientConfig, + MarketplaceListingDoc, + MarketplaceReviewDoc, + MarketplaceInstallDoc, + CreateListingInput, +} from './types.js'; diff --git a/packages/marketplace-client/src/types.ts b/packages/marketplace-client/src/types.ts new file mode 100644 index 00000000..482950d1 --- /dev/null +++ b/packages/marketplace-client/src/types.ts @@ -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; + 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; + 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; + createListing(input: CreateListingInput): Promise; + updateListing( + id: string, + updates: Partial + ): Promise; + submitForCertification(id: string, notes?: string): Promise; + + // Installs + installListing(listingId: string): Promise; + uninstallListing(listingId: string): Promise; + listMyInstalls(query?: { limit?: number; offset?: number }): Promise; + + // Reviews + listReviews( + listingId: string, + query?: { sortBy?: string; limit?: number } + ): Promise; + createReview( + listingId: string, + input: { rating: number; title: string; body: string } + ): Promise; + + // Reports + reportListing(listingId: string, input: { reason: string; details: string }): Promise; +} diff --git a/packages/marketplace-client/tsconfig.json b/packages/marketplace-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/marketplace-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/org-client/package.json b/packages/org-client/package.json new file mode 100644 index 00000000..bfa78144 --- /dev/null +++ b/packages/org-client/package.json @@ -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" + } +} diff --git a/packages/org-client/src/client.test.ts b/packages/org-client/src/client.test.ts new file mode 100644 index 00000000..4dbc8cc4 --- /dev/null +++ b/packages/org-client/src/client.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + 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; + 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; + 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; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer admin-token'); + expect(callHeaders['x-request-id']).toBeDefined(); + }); +}); diff --git a/packages/org-client/src/client.ts b/packages/org-client/src/client.ts new file mode 100644 index 00000000..a2135eee --- /dev/null +++ b/packages/org-client/src/client.ts @@ -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 { + const h: Record = { + '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 { + 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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/org-client/src/index.ts b/packages/org-client/src/index.ts new file mode 100644 index 00000000..f4e778a1 --- /dev/null +++ b/packages/org-client/src/index.ts @@ -0,0 +1,9 @@ +export { createOrgClient } from './client.js'; +export type { + OrgClient, + OrgClientConfig, + OrganizationDoc, + WorkspaceDoc, + MembershipDoc, + LicenseDoc, +} from './types.js'; diff --git a/packages/org-client/src/types.ts b/packages/org-client/src/types.ts new file mode 100644 index 00000000..4ac9a86e --- /dev/null +++ b/packages/org-client/src/types.ts @@ -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; + createdAt: string; + updatedAt: string; +} + +export interface WorkspaceDoc { + id: string; + orgId: string; + productId: string; + name: string; + slug: string; + status: 'active' | 'archived'; + description?: string; + metadata?: Record; + 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; + createOrg(input: { name: string; slug: string; ownerUserId?: string }): Promise; + getOrg(id: string): Promise; + updateOrg(id: string, updates: Partial): Promise; + + // Workspaces + listWorkspaces(orgId: string): Promise; + createWorkspace( + orgId: string, + input: { name: string; slug: string; description?: string } + ): Promise; + updateWorkspace( + orgId: string, + workspaceId: string, + updates: Partial + ): Promise; + + // Memberships + listMemberships( + orgId: string, + query?: { scope?: string; limit?: number } + ): Promise; + addMember( + orgId: string, + input: { userId: string; role?: string; scope?: string; workspaceId?: string } + ): Promise; + updateMember( + orgId: string, + membershipId: string, + updates: { role?: string; status?: string } + ): Promise; + + // Licenses + generateLicense(input: { + userId: string; + plan: string; + maxDevices?: number; + }): Promise; + activateLicense(input: { key: string; deviceId: string }): Promise; + deactivateLicense(input: { key: string; deviceId: string }): Promise; +} diff --git a/packages/org-client/tsconfig.json b/packages/org-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/org-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/quick-actions/package.json b/packages/quick-actions/package.json new file mode 100644 index 00000000..5ee95d11 --- /dev/null +++ b/packages/quick-actions/package.json @@ -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" + } +} diff --git a/packages/quick-actions/src/client.test.ts b/packages/quick-actions/src/client.test.ts new file mode 100644 index 00000000..56ebf008 --- /dev/null +++ b/packages/quick-actions/src/client.test.ts @@ -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); + }); +}); diff --git a/packages/quick-actions/src/client.ts b/packages/quick-actions/src/client.ts new file mode 100644 index 00000000..422b0be2 --- /dev/null +++ b/packages/quick-actions/src/client.ts @@ -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 +): 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]; +} diff --git a/packages/quick-actions/src/index.ts b/packages/quick-actions/src/index.ts new file mode 100644 index 00000000..539e8263 --- /dev/null +++ b/packages/quick-actions/src/index.ts @@ -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'; diff --git a/packages/quick-actions/src/types.ts b/packages/quick-actions/src/types.ts new file mode 100644 index 00000000..ce86fdd2 --- /dev/null +++ b/packages/quick-actions/src/types.ts @@ -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'; +} diff --git a/packages/quick-actions/tsconfig.json b/packages/quick-actions/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/quick-actions/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/referral-client/package.json b/packages/referral-client/package.json new file mode 100644 index 00000000..6f134814 --- /dev/null +++ b/packages/referral-client/package.json @@ -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" + } +} diff --git a/packages/referral-client/src/client.test.ts b/packages/referral-client/src/client.test.ts new file mode 100644 index 00000000..24eeafed --- /dev/null +++ b/packages/referral-client/src/client.test.ts @@ -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 { + 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; + 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; + 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; + 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; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + 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; + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.referrerRewardTokens).toBe(2000); + expect(body.referredRewardTokens).toBe(1000); + }); +}); diff --git a/packages/referral-client/src/client.ts b/packages/referral-client/src/client.ts new file mode 100644 index 00000000..3dac2611 --- /dev/null +++ b/packages/referral-client/src/client.ts @@ -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 { + const h: Record = { + '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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/referral-client/src/index.ts b/packages/referral-client/src/index.ts new file mode 100644 index 00000000..9e297886 --- /dev/null +++ b/packages/referral-client/src/index.ts @@ -0,0 +1,2 @@ +export { createReferralClient } from './client.js'; +export type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js'; diff --git a/packages/referral-client/src/types.ts b/packages/referral-client/src/types.ts new file mode 100644 index 00000000..50674491 --- /dev/null +++ b/packages/referral-client/src/types.ts @@ -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; + updateReferralStatus( + id: string, + referrerId: string, + status: ReferralDoc['status'] + ): Promise; + getByEmail(email: string): Promise; + + // Client-side helpers (pure TS, no network) + buildShareLink(code: string): string; + buildShareMessage(code: string, productName: string): string; + calculateEarnedDays(conversions: number, daysPerReferral?: number): number; +} diff --git a/packages/referral-client/tsconfig.json b/packages/referral-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/referral-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/subscription-client/package.json b/packages/subscription-client/package.json new file mode 100644 index 00000000..f4b7a06b --- /dev/null +++ b/packages/subscription-client/package.json @@ -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" + } +} diff --git a/packages/subscription-client/src/client.test.ts b/packages/subscription-client/src/client.test.ts new file mode 100644 index 00000000..33b8b8e0 --- /dev/null +++ b/packages/subscription-client/src/client.test.ts @@ -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 { + 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 { + 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; + 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 = {}; + 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; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + 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'); + }); +}); diff --git a/packages/subscription-client/src/client.ts b/packages/subscription-client/src/client.ts new file mode 100644 index 00000000..0922773a --- /dev/null +++ b/packages/subscription-client/src/client.ts @@ -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 { + const h: Record = { + '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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + await Promise.all([getMySubscription(), getPlans()]); + } + + return { + getMySubscription, + getPlans, + startTrial, + cancelSubscription, + updateSubscription, + isPro, + isTrialing, + hasFeature, + daysRemaining, + getCachedSubscription, + getCachedPlans, + refresh, + }; +} diff --git a/packages/subscription-client/src/index.ts b/packages/subscription-client/src/index.ts new file mode 100644 index 00000000..0f47e46b --- /dev/null +++ b/packages/subscription-client/src/index.ts @@ -0,0 +1,7 @@ +export { createSubscriptionClient } from './client.js'; +export type { + SubscriptionClient, + SubscriptionClientConfig, + SubscriptionDoc, + PlanConfig, +} from './types.js'; diff --git a/packages/subscription-client/src/types.ts b/packages/subscription-client/src/types.ts new file mode 100644 index 00000000..6092f49a --- /dev/null +++ b/packages/subscription-client/src/types.ts @@ -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; + getPlans(): Promise; + startTrial(planName?: string): Promise; + cancelSubscription(): Promise; + updateSubscription(updates: Partial): Promise; + + // Client-side helpers (cached, offline-safe) + isPro(): boolean; + isTrialing(): boolean; + hasFeature(feature: string): boolean; + daysRemaining(): number | null; + getCachedSubscription(): SubscriptionDoc | null; + getCachedPlans(): PlanConfig[]; + refresh(): Promise; +} diff --git a/packages/subscription-client/tsconfig.json b/packages/subscription-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/subscription-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/time-references/package.json b/packages/time-references/package.json new file mode 100644 index 00000000..f2b82862 --- /dev/null +++ b/packages/time-references/package.json @@ -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" + } +} diff --git a/packages/time-references/src/client.test.ts b/packages/time-references/src/client.test.ts new file mode 100644 index 00000000..db453798 --- /dev/null +++ b/packages/time-references/src/client.test.ts @@ -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'); + }); +}); diff --git a/packages/time-references/src/client.ts b/packages/time-references/src/client.ts new file mode 100644 index 00000000..af153b73 --- /dev/null +++ b/packages/time-references/src/client.ts @@ -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; +} diff --git a/packages/time-references/src/index.ts b/packages/time-references/src/index.ts new file mode 100644 index 00000000..4996d543 --- /dev/null +++ b/packages/time-references/src/index.ts @@ -0,0 +1,8 @@ +export { + getTimeReference, + getEpisodeComparison, + getEncouragingMessage, + registerReferences, + clearCustomReferences, +} from './client.js'; +export type { TimeReference, TimeRangeEntry } from './types.js'; diff --git a/packages/time-references/src/types.ts b/packages/time-references/src/types.ts new file mode 100644 index 00000000..32954805 --- /dev/null +++ b/packages/time-references/src/types.ts @@ -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[]; +} diff --git a/packages/time-references/tsconfig.json b/packages/time-references/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/time-references/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adad5838..077b60c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: