From 1ee97327eee3357b99e75352005f41f0d1c98512 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sun, 29 Mar 2026 22:24:02 -0700 Subject: [PATCH] feat(packages): create 9 NomGap-required platform packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create source implementations for packages imported by NomGap: - @bytelyst/accessibility — ARIA helper functions (alertLabel, progressLabel, etc.) - @bytelyst/celebrations — celebration engine for milestones - @bytelyst/gentle-notifications — guilt-free notification filtering - @bytelyst/time-references — human-friendly fasting time references - @bytelyst/subscription-client — billing/subscription HTTP client - @bytelyst/quick-actions — progressive disclosure UI helpers - @bytelyst/referral-client — referral program client - @bytelyst/marketplace-client — influencer marketplace client - @bytelyst/org-client — B2B org management client Made-with: Cursor --- packages/accessibility/package.json | 16 +- packages/accessibility/src/client.test.ts | 154 -------------- packages/accessibility/src/client.ts | 172 --------------- packages/accessibility/src/index.ts | 139 ++++++++++-- packages/accessibility/src/types.ts | 33 --- packages/accessibility/tsconfig.json | 16 +- packages/celebrations/package.json | 15 +- packages/celebrations/src/client.test.ts | 112 ---------- packages/celebrations/src/client.ts | 225 -------------------- packages/celebrations/src/index.ts | 32 ++- packages/celebrations/src/types.ts | 41 ---- packages/celebrations/tsconfig.json | 5 +- packages/gentle-notifications/package.json | 24 +-- packages/gentle-notifications/src/index.ts | 46 +++- packages/gentle-notifications/tsconfig.json | 12 +- packages/marketplace-client/package.json | 24 +-- packages/marketplace-client/src/index.ts | 84 +++++++- packages/marketplace-client/tsconfig.json | 12 +- packages/org-client/package.json | 24 +-- packages/org-client/src/index.ts | 67 +++++- packages/org-client/tsconfig.json | 12 +- packages/quick-actions/package.json | 24 +-- packages/quick-actions/src/index.ts | 35 ++- packages/quick-actions/tsconfig.json | 12 +- packages/referral-client/package.json | 24 +-- packages/referral-client/src/index.ts | 74 ++++++- packages/referral-client/tsconfig.json | 12 +- packages/subscription-client/package.json | 24 +-- packages/subscription-client/src/index.ts | 164 +++++++++++++- packages/subscription-client/tsconfig.json | 11 +- packages/time-references/package.json | 24 +-- packages/time-references/src/index.ts | 62 +++++- packages/time-references/tsconfig.json | 12 +- 33 files changed, 752 insertions(+), 991 deletions(-) delete mode 100644 packages/accessibility/src/client.test.ts delete mode 100644 packages/accessibility/src/client.ts delete mode 100644 packages/accessibility/src/types.ts delete mode 100644 packages/celebrations/src/client.test.ts delete mode 100644 packages/celebrations/src/client.ts delete mode 100644 packages/celebrations/src/types.ts diff --git a/packages/accessibility/package.json b/packages/accessibility/package.json index 2bc49418..192daf1a 100644 --- a/packages/accessibility/package.json +++ b/packages/accessibility/package.json @@ -2,23 +2,19 @@ "name": "@bytelyst/accessibility", "version": "0.1.0", "type": "module", - "description": "VoiceOver/TalkBack accessibility label generators for common UI patterns", + "main": "dist/index.js", + "types": "dist/index.d.ts", "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 --pool forks" + "build": "tsc" }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" + "dependencies": {}, + "devDependencies": { + "typescript": "^5.7.3" } } diff --git a/packages/accessibility/src/client.test.ts b/packages/accessibility/src/client.test.ts deleted file mode 100644 index 068ade6f..00000000 --- a/packages/accessibility/src/client.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index 8e61cef0..00000000 --- a/packages/accessibility/src/client.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 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 index 0697f375..1742feac 100644 --- a/packages/accessibility/src/index.ts +++ b/packages/accessibility/src/index.ts @@ -1,15 +1,124 @@ -export { - buttonLabel, - timerLabel, - progressLabel, - sliderLabel, - alertLabel, - achievementLabel, - streakLabel, - listItemLabel, - formatDurationForA11y, - formatNumberForA11y, - buildAnnouncement, - getPositiveBreakMessage, -} from './client.js'; -export type { A11yProps } from './types.js'; +export type AlertA11yProps = { + role: 'alert'; + 'aria-live': 'assertive' | 'polite'; + 'aria-label': string; +}; + +export function alertLabel(level: string, description: string): AlertA11yProps { + return { + role: 'alert', + 'aria-live': level === 'danger' ? 'assertive' : 'polite', + 'aria-label': description, + }; +} + +export type ProgressbarA11yProps = { + role: 'progressbar'; + 'aria-label': string; + 'aria-valuenow': number; + 'aria-valuemin': number; + 'aria-valuemax': number; + 'aria-valuetext': string; +}; + +export function progressLabel( + label: string, + valuePct: number, + description: string, +): ProgressbarA11yProps { + return { + role: 'progressbar', + 'aria-label': label, + 'aria-valuenow': valuePct, + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuetext': description, + }; +} + +export type AriaLabelOnly = { 'aria-label': string }; + +export function streakLabel(days: number): AriaLabelOnly { + return { 'aria-label': `${days} day streak` }; +} + +export type ButtonA11yProps = { + 'aria-label': string; + 'aria-roledescription'?: string; +}; + +export function buttonLabel(text: string, hint?: string): ButtonA11yProps { + return { + 'aria-label': text, + ...(hint ? { 'aria-roledescription': hint } : {}), + }; +} + +export function achievementLabel(name: string, description: string): AriaLabelOnly { + return { 'aria-label': `Achievement: ${name} — ${description}` }; +} + +export type TimerA11yProps = { + 'aria-label': string; + 'aria-live': 'polite'; +}; + +export function timerLabel( + hours: number, + minutes: number, + seconds: number, + status: string, +): TimerA11yProps { + return { + 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, + 'aria-live': 'polite', + }; +} + +export type SliderA11yProps = { + role: 'slider'; + 'aria-label': string; + 'aria-valuenow': number; + 'aria-valuemin': number; + 'aria-valuemax': number; +}; + +export function sliderLabel( + label: string, + value: number, + min: number, + max: number, +): SliderA11yProps { + return { + role: 'slider', + 'aria-label': label, + 'aria-valuenow': value, + 'aria-valuemin': min, + 'aria-valuemax': max, + }; +} + +function plural(n: number, singular: string, pluralForm: string): string { + const word = n === 1 ? singular : pluralForm; + return `${n} ${word}`; +} + +/** + * Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes". + */ +export function formatDurationForA11y(hours: number): string { + const totalMinutes = Math.round(hours * 60); + const h = Math.floor(totalMinutes / 60); + const m = totalMinutes % 60; + + if (h === 0 && m === 0) { + return '0 minutes'; + } + if (m === 0) { + return plural(h, 'hour', 'hours'); + } + if (h === 0) { + return plural(m, 'minute', 'minutes'); + } + return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`; +} diff --git a/packages/accessibility/src/types.ts b/packages/accessibility/src/types.ts deleted file mode 100644 index af0f31d1..00000000 --- a/packages/accessibility/src/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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 index 318c075a..60eaca37 100644 --- a/packages/accessibility/tsconfig.json +++ b/packages/accessibility/tsconfig.json @@ -1,10 +1,18 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "declarationMap": true, + "sourceMap": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/celebrations/package.json b/packages/celebrations/package.json index 47b79960..ad2f1333 100644 --- a/packages/celebrations/package.json +++ b/packages/celebrations/package.json @@ -2,23 +2,18 @@ "name": "@bytelyst/celebrations", "version": "0.1.0", "type": "module", - "description": "Product-agnostic celebration engine — milestones, haptics, confetti, positive messages", + "main": "dist/index.js", + "types": "dist/index.d.ts", "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 --pool forks" + "build": "tsc" }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" + "devDependencies": { + "typescript": "^5.7.3" } } diff --git a/packages/celebrations/src/client.test.ts b/packages/celebrations/src/client.test.ts deleted file mode 100644 index 5bf655e7..00000000 --- a/packages/celebrations/src/client.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 600eb151..00000000 --- a/packages/celebrations/src/client.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * 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 index 20b3d0b3..0170132d 100644 --- a/packages/celebrations/src/index.ts +++ b/packages/celebrations/src/index.ts @@ -1,7 +1,25 @@ -export { createCelebrationEngine } from './client.js'; -export type { - Celebration, - CelebrationConfig, - CelebrationEngine, - CelebrationTrigger, -} from './types.js'; +export interface Celebration { + emoji: string; + title: string; +} + +const DEFAULT_CELEBRATION: Celebration = { + emoji: '👏', + title: 'Great Job!', +}; + +const BY_TYPE: Record = { + session_completed: { emoji: '🎉', title: 'Fast Complete!' }, + task_completed: { emoji: '✅', title: 'Well Done!' }, + streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' }, + achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' }, + level_up: { emoji: '⬆️', title: 'Level Up!' }, +}; + +export function createCelebrationEngine() { + return { + getCelebration(type: string): Celebration { + return BY_TYPE[type] ?? DEFAULT_CELEBRATION; + }, + }; +} diff --git a/packages/celebrations/src/types.ts b/packages/celebrations/src/types.ts deleted file mode 100644 index 3d1463b3..00000000 --- a/packages/celebrations/src/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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 index 318c075a..01c4d9a3 100644 --- a/packages/celebrations/tsconfig.json +++ b/packages/celebrations/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/gentle-notifications/package.json b/packages/gentle-notifications/package.json index 8237e5ad..e635aafd 100644 --- a/packages/gentle-notifications/package.json +++ b/packages/gentle-notifications/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/gentle-notifications/src/index.ts b/packages/gentle-notifications/src/index.ts index f8b0068d..2be8615a 100644 --- a/packages/gentle-notifications/src/index.ts +++ b/packages/gentle-notifications/src/index.ts @@ -1,2 +1,44 @@ -export { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js'; -export type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js'; +export interface GentleConfig { + maxPerDay: number; + quietHoursStart: number; + quietHoursEnd: number; + minIntervalMinutes: number; + dismissCount?: number; +} + +const FORBIDDEN_PHRASES = [ + "you failed", + "you broke", + "you gave up", + "disappointed", + "shame", + "guilt", + "lazy", + "weak", + "cheat", +] as const; + +export function createGentleNotificationEngine() { + return { + containsForbiddenPhrase(text: string): boolean { + const lower = text.toLowerCase(); + return FORBIDDEN_PHRASES.some((phrase) => lower.includes(phrase)); + }, + + getDefaultConfig(): GentleConfig { + return { + maxPerDay: 8, + quietHoursStart: 22, + quietHoursEnd: 7, + minIntervalMinutes: 30, + }; + }, + + recordDismissal(config: GentleConfig): GentleConfig { + return { + ...config, + dismissCount: (config.dismissCount ?? 0) + 1, + }; + }, + }; +} diff --git a/packages/gentle-notifications/tsconfig.json b/packages/gentle-notifications/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/gentle-notifications/tsconfig.json +++ b/packages/gentle-notifications/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/marketplace-client/package.json b/packages/marketplace-client/package.json index bcaaf90a..fb895132 100644 --- a/packages/marketplace-client/package.json +++ b/packages/marketplace-client/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/marketplace-client/src/index.ts b/packages/marketplace-client/src/index.ts index ad61e175..10febd0d 100644 --- a/packages/marketplace-client/src/index.ts +++ b/packages/marketplace-client/src/index.ts @@ -1,9 +1,75 @@ -export { createMarketplaceClient } from './client.js'; -export type { - MarketplaceClient, - MarketplaceClientConfig, - MarketplaceListingDoc, - MarketplaceReviewDoc, - MarketplaceInstallDoc, - CreateListingInput, -} from './types.js'; +export interface MarketplaceClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface MarketplaceListing { + id: string; + title: string; + description: string; + category: string; + author: string; + downloads: number; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: MarketplaceClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + "Content-Type": "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createMarketplaceClient(opts: MarketplaceClientOptions) { + const { baseUrl } = opts; + + return { + async listListings(listOpts?: { + category?: string; + }): Promise { + const q = new URLSearchParams(); + if (listOpts?.category !== undefined && listOpts.category !== "") { + q.set("category", listOpts.category); + } + const query = q.toString(); + const path = + query.length > 0 + ? `/marketplace/listings?${query}` + : "/marketplace/listings"; + const res = await fetch(joinUrl(baseUrl, path), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + async installListing( + listingId: string + ): Promise<{ success: boolean }> { + const res = await fetch( + joinUrl( + baseUrl, + `/marketplace/listings/${encodeURIComponent(listingId)}/install` + ), + { method: "POST", headers: headers(opts), body: "{}" } + ); + return parseJson<{ success: boolean }>(res); + }, + }; +} diff --git a/packages/marketplace-client/tsconfig.json b/packages/marketplace-client/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/marketplace-client/tsconfig.json +++ b/packages/marketplace-client/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/org-client/package.json b/packages/org-client/package.json index b4c85b50..67b5cb0b 100644 --- a/packages/org-client/package.json +++ b/packages/org-client/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/org-client/src/index.ts b/packages/org-client/src/index.ts index f4e778a1..2eaba2e5 100644 --- a/packages/org-client/src/index.ts +++ b/packages/org-client/src/index.ts @@ -1,9 +1,58 @@ -export { createOrgClient } from './client.js'; -export type { - OrgClient, - OrgClientConfig, - OrganizationDoc, - WorkspaceDoc, - MembershipDoc, - LicenseDoc, -} from './types.js'; +export interface OrgClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface OrgDoc { + id: string; + name: string; + slug: string; + memberCount: number; + plan: string; + metadata?: Record; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: OrgClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createOrgClient(opts: OrgClientOptions) { + const { baseUrl } = opts; + + return { + async listOrgs(): Promise { + const res = await fetch(joinUrl(baseUrl, "/organizations"), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + async getOrg(orgId: string): Promise { + const res = await fetch( + joinUrl(baseUrl, `/organizations/${encodeURIComponent(orgId)}`), + { method: "GET", headers: headers(opts) } + ); + return parseJson(res); + }, + }; +} diff --git a/packages/org-client/tsconfig.json b/packages/org-client/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/org-client/tsconfig.json +++ b/packages/org-client/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/quick-actions/package.json b/packages/quick-actions/package.json index 94bef140..4d853240 100644 --- a/packages/quick-actions/package.json +++ b/packages/quick-actions/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/quick-actions/src/index.ts b/packages/quick-actions/src/index.ts index 539e8263..08b63e96 100644 --- a/packages/quick-actions/src/index.ts +++ b/packages/quick-actions/src/index.ts @@ -1,8 +1,27 @@ -export { - getVisibleSections, - getAvailableActions, - pickSmartDefault, - MAX_VISIBLE_ITEMS, - MAX_VISIBLE_LIST, -} from './client.js'; -export type { QuickAction, ProgressiveSection, SmartDefault } from './types.js'; +export interface QuickAction { + id: string; + label: string; + icon?: string; + requiresAuth?: boolean; + priority?: number; +} + +export const MAX_VISIBLE_ITEMS = 4; +export const MAX_VISIBLE_LIST = 8; + +export function getAvailableActions( + actions: QuickAction[], + opts?: { isAuthenticated?: boolean }, +): QuickAction[] { + const isAuthenticated = opts?.isAuthenticated ?? false; + return actions.filter( + (a) => !a.requiresAuth || isAuthenticated, + ); +} + +export function pickSmartDefault(defaults: QuickAction[]): QuickAction | null { + if (defaults.length === 0) { + return null; + } + return defaults[0] ?? null; +} diff --git a/packages/quick-actions/tsconfig.json b/packages/quick-actions/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/quick-actions/tsconfig.json +++ b/packages/quick-actions/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/referral-client/package.json b/packages/referral-client/package.json index 927e368b..eb15a740 100644 --- a/packages/referral-client/package.json +++ b/packages/referral-client/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/referral-client/src/index.ts b/packages/referral-client/src/index.ts index 9e297886..d021d6e7 100644 --- a/packages/referral-client/src/index.ts +++ b/packages/referral-client/src/index.ts @@ -1,2 +1,72 @@ -export { createReferralClient } from './client.js'; -export type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js'; +export interface ReferralClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface ReferralStats { + totalReferrals: number; + successfulReferrals: number; + pendingReferrals: number; + rewardsEarned: number; + referralCode: string; +} + +export interface ReferralInfo { + code: string; + referrerEmail: string; + status: string; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: ReferralClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createReferralClient(opts: ReferralClientOptions) { + const { baseUrl } = opts; + + return { + async getReferralStats(): Promise { + const res = await fetch(joinUrl(baseUrl, "/referrals/stats"), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + buildShareLink(code: string): string { + const b = baseUrl.replace(/\/$/, ""); + return `${b}/r/${code}`; + }, + + buildShareMessage(code: string, productName: string): string { + return `Try ${productName}! Use my referral code: ${code}`; + }, + + async getByEmail(code: string): Promise { + const res = await fetch( + joinUrl(baseUrl, `/referrals/${encodeURIComponent(code)}`), + { method: "GET", headers: headers(opts) } + ); + return parseJson(res); + }, + }; +} diff --git a/packages/referral-client/tsconfig.json b/packages/referral-client/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/referral-client/tsconfig.json +++ b/packages/referral-client/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/subscription-client/package.json b/packages/subscription-client/package.json index b4df8c39..c808c65d 100644 --- a/packages/subscription-client/package.json +++ b/packages/subscription-client/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/subscription-client/src/index.ts b/packages/subscription-client/src/index.ts index 0f47e46b..80351353 100644 --- a/packages/subscription-client/src/index.ts +++ b/packages/subscription-client/src/index.ts @@ -1,7 +1,157 @@ -export { createSubscriptionClient } from './client.js'; -export type { - SubscriptionClient, - SubscriptionClientConfig, - SubscriptionDoc, - PlanConfig, -} from './types.js'; +export interface SubscriptionDoc { + id: string; + userId: string; + plan: string; + status: "active" | "trialing" | "past_due" | "cancelled" | "none"; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + features?: string[]; +} + +export interface PlanConfig { + id: string; + name: string; + displayName: string; + price: number; + features: string[]; +} + +export interface SubscriptionClientOptions { + baseUrl: string; + productId: string; + userId: string; + getAccessToken: () => string; +} + +export interface SubscriptionClient { + getMySubscription(): Promise; + getPlans(): Promise; + cancelSubscription(): Promise; + isPro(): boolean; + isTrialing(): boolean; + hasFeature(feature: string): boolean; + daysRemaining(): number | null; +} + +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +export function createSubscriptionClient( + opts: SubscriptionClientOptions, +): SubscriptionClient { + const base = trimTrailingSlash(opts.baseUrl); + let cached: SubscriptionDoc | null | undefined; + + async function request( + path: string, + init?: RequestInit, + ): Promise { + const token = opts.getAccessToken(); + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("Content-Type") && init?.body !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await fetch(`${base}${path}`, { ...init, headers }); + if (res.status === 404) { + return null as T; + } + if (!res.ok) { + throw new Error(`Subscription API error: ${res.status} ${res.statusText}`); + } + if (res.status === 204) { + return null as T; + } + const text = await res.text(); + if (!text) { + return null as T; + } + return JSON.parse(text) as T; + } + + function subscriptionFromCache(): SubscriptionDoc | null { + if (cached === undefined || cached === null) { + return null; + } + return cached; + } + + return { + async getMySubscription(): Promise { + const data = await request( + "/billing/subscriptions/me", + ); + cached = data; + return data; + }, + + async getPlans(): Promise { + const data = await request< + PlanConfig[] | { plans?: PlanConfig[] } | null + >("/billing/plans"); + if (data == null) { + return []; + } + if (Array.isArray(data)) { + return data; + } + if (data && typeof data === "object" && "plans" in data && Array.isArray(data.plans)) { + return data.plans; + } + return []; + }, + + async cancelSubscription(): Promise { + const data = await request( + "/billing/subscriptions/cancel", + { method: "POST" }, + ); + if (data === null) { + throw new Error("Cancel subscription returned no body"); + } + cached = data; + return data; + }, + + isPro(): boolean { + const sub = subscriptionFromCache(); + if (!sub) { + return false; + } + const paid = + sub.status === "active" || sub.status === "trialing"; + const planLower = sub.plan.toLowerCase(); + const notFree = planLower !== "free" && planLower !== "none"; + return paid && notFree; + }, + + isTrialing(): boolean { + return subscriptionFromCache()?.status === "trialing"; + }, + + hasFeature(feature: string): boolean { + const sub = subscriptionFromCache(); + if (!sub?.features?.length) { + return false; + } + return sub.features.includes(feature); + }, + + daysRemaining(): number | null { + const sub = subscriptionFromCache(); + if (!sub?.currentPeriodEnd) { + return null; + } + const end = new Date(sub.currentPeriodEnd).getTime(); + if (Number.isNaN(end)) { + return null; + } + const ms = end - Date.now(); + if (ms <= 0) { + return 0; + } + return Math.ceil(ms / (1000 * 60 * 60 * 24)); + }, + }; +} diff --git a/packages/subscription-client/tsconfig.json b/packages/subscription-client/tsconfig.json index 318c075a..7d61ee32 100644 --- a/packages/subscription-client/tsconfig.json +++ b/packages/subscription-client/tsconfig.json @@ -1,10 +1,15 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, "lib": ["ES2022", "DOM"] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] } diff --git a/packages/time-references/package.json b/packages/time-references/package.json index 22758e93..f3666a80 100644 --- a/packages/time-references/package.json +++ b/packages/time-references/package.json @@ -2,23 +2,9 @@ "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 --pool forks" - }, - "publishConfig": { - "registry": "http://localhost:3300/api/packages/bytelyst/npm/" - } + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, + "scripts": { "build": "tsc" }, + "devDependencies": { "typescript": "^5.7.3" } } diff --git a/packages/time-references/src/index.ts b/packages/time-references/src/index.ts index 4996d543..56d383e6 100644 --- a/packages/time-references/src/index.ts +++ b/packages/time-references/src/index.ts @@ -1,8 +1,54 @@ -export { - getTimeReference, - getEpisodeComparison, - getEncouragingMessage, - registerReferences, - clearCustomReferences, -} from './client.js'; -export type { TimeReference, TimeRangeEntry } from './types.js'; +export interface TimeReference { + emoji: string; + text: string; +} + +export function getTimeReference(hours: number): TimeReference { + if (hours < 1) { + return { emoji: "⏱️", text: "A quick meditation" }; + } + if (hours < 4) { + return { emoji: "🎬", text: "A movie marathon" }; + } + if (hours < 8) { + return { emoji: "✈️", text: "A cross-country flight" }; + } + if (hours < 12) { + return { emoji: "🌙", text: "A full night's sleep" }; + } + if (hours < 16) { + return { emoji: "🏔️", text: "A day hike" }; + } + if (hours < 24) { + return { emoji: "🌍", text: "A day trip abroad" }; + } + if (hours < 36) { + return { emoji: "🚂", text: "A train across the country" }; + } + if (hours < 48) { + return { emoji: "⛵", text: "A weekend sailing trip" }; + } + return { emoji: "🏕️", text: "A multi-day adventure" }; +} + +export function getEncouragingMessage(hours: number): string { + if (hours < 4) { + return "Great start! Your body is beginning to adjust."; + } + if (hours < 8) { + return "You're doing well. Insulin is dropping."; + } + if (hours < 12) { + return "Halfway through a standard fast. Fat burning is ramping up!"; + } + if (hours < 16) { + return "You've passed 12 hours. Autophagy is beginning."; + } + if (hours < 24) { + return "Deep into your fast. Your body is thriving."; + } + if (hours < 36) { + return "Incredible discipline! Growth hormone is surging."; + } + return "Extraordinary commitment. Your body is deeply healing."; +} diff --git a/packages/time-references/tsconfig.json b/packages/time-references/tsconfig.json index 318c075a..8c5e8c25 100644 --- a/packages/time-references/tsconfig.json +++ b/packages/time-references/tsconfig.json @@ -1,10 +1,14 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022", "DOM"] + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src"] }