@rlse/widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @rlse/widget
2
+
3
+ React component for embedding rlse.dev release notes in your application.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @rlse/widget
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { RlseWidget } from '@rlse/widget';
15
+
16
+ function App() {
17
+ return (
18
+ <RlseWidget
19
+ orgSlug="your-org-slug"
20
+ trigger="both"
21
+ position="bottom-right"
22
+ />
23
+ );
24
+ }
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ See full documentation at [docs/WIDGET_SETUP.md](../../docs/WIDGET_SETUP.md).
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ bun install
35
+ bun run build
36
+ ```
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import type { WidgetConfig, Change } from './types';
3
+ interface ChangesModalProps {
4
+ config: Required<WidgetConfig>;
5
+ changes: Change[];
6
+ isLoading: boolean;
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ onMarkAsRead: () => void;
10
+ }
11
+ export declare function ChangesModal({ config, changes, isLoading, isOpen, onClose, onMarkAsRead, }: ChangesModalProps): React.ReactElement | null;
12
+ export {};
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect } from 'react';
3
+ import { markAllChangesAsSeen, getUnreadCount } from './storage';
4
+ /**
5
+ * Render a change description for display in the widget.
6
+ * - HTML content (new format, stored after the rich-content migration): passed
7
+ * through directly after stripping event-handler attributes and javascript: URLs.
8
+ * Content is sanitised at the write path before storage.
9
+ * - Markdown content (legacy records): converted via simpleMarkdownToHtml so
10
+ * older records continue to render correctly without a DB migration.
11
+ */
12
+ function renderDescription(content) {
13
+ if (!content)
14
+ return '';
15
+ if (content.trimStart().startsWith('<')) {
16
+ return content
17
+ .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '')
18
+ .replace(/href\s*=\s*"javascript:[^"]*"/gi, '')
19
+ .replace(/href\s*=\s*'javascript:[^']*'/gi, '');
20
+ }
21
+ return simpleMarkdownToHtml(content);
22
+ }
23
+ function simpleMarkdownToHtml(markdown) {
24
+ // Helper to sanitize URLs - only allow safe protocols
25
+ const isSafeUrl = (url) => {
26
+ try {
27
+ const parsed = new URL(url, 'http://example.com');
28
+ const protocol = parsed.protocol.replace(':', '');
29
+ return ['http', 'https', 'mailto', 'tel'].includes(protocol);
30
+ }
31
+ catch {
32
+ // Relative URLs are OK
33
+ return (url.startsWith('/') ||
34
+ url.startsWith('./') ||
35
+ url.startsWith('../') ||
36
+ !url.includes(':'));
37
+ }
38
+ };
39
+ return markdown
40
+ .replace(/&/g, '&amp;')
41
+ .replace(/</g, '&lt;')
42
+ .replace(/>/g, '&gt;')
43
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
44
+ .replace(/__(.+?)__/g, '<strong>$1</strong>')
45
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
46
+ .replace(/_(.+?)_/g, '<em>$1</em>')
47
+ .replace(/`(.+?)`/g, '<code>$1</code>')
48
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
49
+ if (isSafeUrl(url)) {
50
+ // Escape quotes in URL to prevent injection
51
+ const safeUrl = url.replace(/"/g, '%22').replace(/'/g, '%27');
52
+ return `<a href="${safeUrl}" target="_blank" rel="noopener">${text}</a>`;
53
+ }
54
+ return text; // Return plain text for unsafe URLs
55
+ })
56
+ .split('\n\n')
57
+ .map((para) => `<p>${para}</p>`)
58
+ .join('');
59
+ }
60
+ function ChangeCard({ change, showStatus, showDates, }) {
61
+ const date = new Date(change._creationTime).toLocaleDateString('en-US', {
62
+ year: 'numeric',
63
+ month: 'short',
64
+ day: 'numeric',
65
+ });
66
+ const descriptionHtml = renderDescription(change.changeDescription);
67
+ return (_jsxs("article", { style: { padding: '20px 0', borderBottom: '1px solid var(--border)' }, children: [_jsxs("div", { style: {
68
+ display: 'flex',
69
+ alignItems: 'flex-start',
70
+ justifyContent: 'space-between',
71
+ gap: 12,
72
+ marginBottom: 8,
73
+ }, children: [_jsx("h3", { style: { margin: 0, fontSize: 16, fontWeight: 600 }, children: change.changeName }), showStatus && (_jsx("span", { style: {
74
+ fontSize: 12,
75
+ fontWeight: 500,
76
+ padding: '4px 10px',
77
+ borderRadius: 20,
78
+ background: 'rgba(59, 130, 246, 0.1)',
79
+ color: '#3b82f6',
80
+ border: '1px solid #3b82f6',
81
+ whiteSpace: 'nowrap',
82
+ }, children: change.currentStatus }))] }), _jsx("p", { style: {
83
+ margin: '8px 0',
84
+ fontSize: 14,
85
+ color: '#64748b',
86
+ lineHeight: 1.5,
87
+ }, children: change.changeSummary }), _jsx("div", { style: { margin: '12px 0 0', fontSize: 14, lineHeight: 1.6 }, dangerouslySetInnerHTML: { __html: descriptionHtml } }), showDates && (_jsx("div", { style: { marginTop: 12, fontSize: 12, color: '#64748b' }, children: date }))] }));
88
+ }
89
+ export function ChangesModal({ config, changes, isLoading, isOpen, onClose, onMarkAsRead, }) {
90
+ const handleMarkAsRead = useCallback(() => {
91
+ const changeIds = changes.map((c) => c._id);
92
+ markAllChangesAsSeen(config.orgSlug, changeIds);
93
+ onMarkAsRead();
94
+ }, [changes, config.orgSlug, onMarkAsRead]);
95
+ // Close on escape key
96
+ useEffect(() => {
97
+ if (!isOpen)
98
+ return;
99
+ const handleEscape = (e) => {
100
+ if (e.key === 'Escape') {
101
+ onClose();
102
+ }
103
+ };
104
+ document.addEventListener('keydown', handleEscape);
105
+ document.body.style.overflow = 'hidden';
106
+ return () => {
107
+ document.removeEventListener('keydown', handleEscape);
108
+ document.body.style.overflow = '';
109
+ };
110
+ }, [isOpen, onClose]);
111
+ if (!isOpen)
112
+ return null;
113
+ const releaseNotesUrl = config.appSlug
114
+ ? `${config.baseUrl}/${config.orgSlug}/${config.appSlug}`
115
+ : `${config.baseUrl}/${config.orgSlug}`;
116
+ const unreadCount = getUnreadCount(config.orgSlug, changes);
117
+ return (_jsx("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "rlse-widget-title", onClick: (e) => {
118
+ if (e.target === e.currentTarget) {
119
+ onClose();
120
+ }
121
+ }, style: {
122
+ position: 'fixed',
123
+ inset: 0,
124
+ background: 'rgba(0, 0, 0, 0.5)',
125
+ zIndex: 9999,
126
+ display: 'flex',
127
+ alignItems: 'center',
128
+ justifyContent: 'center',
129
+ padding: 20,
130
+ }, children: _jsxs("div", { style: {
131
+ background: 'var(--background, white)',
132
+ borderRadius: 12,
133
+ width: '100%',
134
+ maxWidth: 600,
135
+ maxHeight: '80vh',
136
+ display: 'flex',
137
+ flexDirection: 'column',
138
+ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
139
+ }, children: [_jsxs("div", { style: {
140
+ display: 'flex',
141
+ alignItems: 'center',
142
+ justifyContent: 'space-between',
143
+ padding: '20px 24px',
144
+ borderBottom: '1px solid var(--border, #e2e8f0)',
145
+ }, children: [_jsx("h2", { id: "rlse-widget-title", style: { margin: 0, fontSize: 18, fontWeight: 600 }, children: config.modalTitle }), _jsx("button", { onClick: onClose, "aria-label": "Close", style: {
146
+ background: 'none',
147
+ border: 'none',
148
+ cursor: 'pointer',
149
+ padding: 8,
150
+ borderRadius: 8,
151
+ display: 'flex',
152
+ alignItems: 'center',
153
+ justifyContent: 'center',
154
+ }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M18 6 6 18" }), _jsx("path", { d: "m6 6 12 12" })] }) })] }), _jsx("div", { style: { overflowY: 'auto', padding: '0 24px 24px', flex: 1 }, children: isLoading ? (_jsx("div", { style: { padding: 40, textAlign: 'center' }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { animation: 'spin 1s linear infinite' }, children: [_jsx("style", { children: `@keyframes spin{to{transform:rotate(360deg)}}` }), _jsx("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" })] }) })) : changes.length === 0 ? (_jsx("div", { style: { padding: 40, textAlign: 'center', color: '#64748b' }, children: _jsx("p", { children: "No release notes yet. Check back soon!" }) })) : (changes.map((change) => (_jsx(ChangeCard, { change: change, showStatus: config.showStatus, showDates: config.showDates }, change._id)))) }), !isLoading && changes.length > 0 && (_jsxs("div", { style: {
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'space-between',
158
+ padding: '16px 24px',
159
+ borderTop: '1px solid var(--border, #e2e8f0)',
160
+ gap: 12,
161
+ }, children: [_jsx("button", { onClick: handleMarkAsRead, disabled: unreadCount === 0, style: {
162
+ fontSize: 14,
163
+ padding: '8px 16px',
164
+ borderRadius: 8,
165
+ border: 'none',
166
+ background: config.primaryColor || '#3b82f6',
167
+ color: 'white',
168
+ cursor: unreadCount === 0 ? 'default' : 'pointer',
169
+ fontWeight: 500,
170
+ opacity: unreadCount === 0 ? 0.5 : 1,
171
+ }, children: unreadCount === 0 ? 'All caught up' : 'Mark all as read' }), _jsx("a", { href: releaseNotesUrl, target: "_blank", rel: "noopener", style: {
172
+ fontSize: 14,
173
+ color: config.primaryColor || '#3b82f6',
174
+ textDecoration: 'none',
175
+ fontWeight: 500,
176
+ }, children: "View all \u2192" })] }))] }) }));
177
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ import type { WidgetConfig } from './types';
3
+ export interface RlseWidgetProps extends WidgetConfig {
4
+ }
5
+ export declare function RlseWidget(props: RlseWidgetProps): React.ReactElement | null;
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { DEFAULT_CONFIG } from './types';
4
+ import { useWidgetData } from './useWidgetData';
5
+ import { TriggerButton } from './TriggerButton';
6
+ import { ChangesModal } from './ChangesModal';
7
+ import { setLastVisit, getUnreadCount, shouldAutoShow } from './storage';
8
+ export function RlseWidget(props) {
9
+ const config = { ...DEFAULT_CONFIG, ...props };
10
+ // Hooks must be called before any early return
11
+ const { changes, isLoading, refetch } = useWidgetData(config.orgSlug, config.appSlug, config.limit, config.baseUrl);
12
+ const [isModalOpen, setIsModalOpen] = useState(false);
13
+ const [hasAutoShown, setHasAutoShown] = useState(false);
14
+ const prevIsModalOpenRef = useRef(isModalOpen);
15
+ const handleOpen = useCallback(() => {
16
+ setIsModalOpen(true);
17
+ }, []);
18
+ const handleClose = useCallback(() => {
19
+ setIsModalOpen(false);
20
+ }, []);
21
+ const handleMarkAsRead = useCallback(() => {
22
+ // Force re-render to update badge
23
+ refetch();
24
+ }, [refetch]);
25
+ // Validate after hooks
26
+ if (!config.orgSlug) {
27
+ console.error('RlseWidget: orgSlug is required');
28
+ return null;
29
+ }
30
+ // Auto-show logic
31
+ useEffect(() => {
32
+ if (!config.orgSlug)
33
+ return; // Guard: noop when orgSlug is falsy
34
+ if (hasAutoShown)
35
+ return;
36
+ if (config.trigger === 'manual')
37
+ return;
38
+ if (isLoading || changes.length === 0)
39
+ return;
40
+ const unreadCount = getUnreadCount(config.orgSlug, changes);
41
+ if (unreadCount === 0)
42
+ return;
43
+ const shouldShow = shouldAutoShow(config.orgSlug, config.autoShowAfter);
44
+ if (shouldShow) {
45
+ const timer = setTimeout(() => {
46
+ setIsModalOpen(true);
47
+ setHasAutoShown(true);
48
+ }, 1000);
49
+ return () => clearTimeout(timer);
50
+ }
51
+ // Update last visit
52
+ setLastVisit(config.orgSlug);
53
+ return undefined;
54
+ }, [
55
+ hasAutoShown,
56
+ config.trigger,
57
+ config.orgSlug,
58
+ config.autoShowAfter,
59
+ isLoading,
60
+ changes,
61
+ ]);
62
+ // Update last visit only when modal transitions from open to closed
63
+ useEffect(() => {
64
+ if (!config.orgSlug)
65
+ return; // Guard: noop when orgSlug is falsy
66
+ if (prevIsModalOpenRef.current && !isModalOpen) {
67
+ setLastVisit(config.orgSlug);
68
+ }
69
+ prevIsModalOpenRef.current = isModalOpen;
70
+ }, [isModalOpen, config.orgSlug]);
71
+ // Don't render trigger if trigger is 'auto' only
72
+ if (config.trigger === 'auto') {
73
+ return (_jsx(ChangesModal, { config: config, changes: changes, isLoading: isLoading, isOpen: isModalOpen, onClose: handleClose, onMarkAsRead: handleMarkAsRead }));
74
+ }
75
+ return (_jsxs(_Fragment, { children: [_jsx(TriggerButton, { config: config, changes: changes, onClick: handleOpen }), _jsx(ChangesModal, { config: config, changes: changes, isLoading: isLoading, isOpen: isModalOpen, onClose: handleClose, onMarkAsRead: handleMarkAsRead })] }));
76
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { WidgetConfig } from './types';
3
+ import type { Change } from './types';
4
+ interface TriggerButtonProps {
5
+ config: Required<WidgetConfig>;
6
+ changes: Change[];
7
+ onClick: () => void;
8
+ }
9
+ export declare function TriggerButton({ config, changes, onClick, }: TriggerButtonProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { getUnreadCount } from './storage';
3
+ const positionStyles = {
4
+ 'bottom-right': { position: 'fixed', bottom: 20, right: 20 },
5
+ 'bottom-left': { position: 'fixed', bottom: 20, left: 20 },
6
+ 'top-right': { position: 'fixed', top: 20, right: 20 },
7
+ 'top-left': { position: 'fixed', top: 20, left: 20 },
8
+ };
9
+ export function TriggerButton({ config, changes, onClick, }) {
10
+ const unreadCount = getUnreadCount(config.orgSlug, changes);
11
+ return (_jsxs("button", { onClick: onClick, "aria-label": config.triggerLabel, style: {
12
+ ...positionStyles[config.position],
13
+ zIndex: 9998,
14
+ width: 56,
15
+ height: 56,
16
+ borderRadius: '50%',
17
+ border: 'none',
18
+ cursor: 'pointer',
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ background: config.primaryColor || '#3b82f6',
23
+ color: 'white',
24
+ boxShadow: '0 4px 14px 0 rgba(59, 130, 246, 0.39)',
25
+ transition: 'transform 0.2s ease, box-shadow 0.2s ease',
26
+ }, onMouseEnter: (e) => {
27
+ e.currentTarget.style.transform = 'scale(1.05)';
28
+ e.currentTarget.style.boxShadow =
29
+ '0 6px 20px 0 rgba(59, 130, 246, 0.23)';
30
+ }, onMouseLeave: (e) => {
31
+ e.currentTarget.style.transform = 'scale(1)';
32
+ e.currentTarget.style.boxShadow =
33
+ '0 4px 14px 0 rgba(59, 130, 246, 0.39)';
34
+ }, children: [_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" }), _jsx("path", { d: "M5 3v4" }), _jsx("path", { d: "M19 17v4" }), _jsx("path", { d: "M3 5h4" }), _jsx("path", { d: "M17 19h4" })] }), unreadCount > 0 && (_jsx("span", { style: {
35
+ position: 'absolute',
36
+ top: -2,
37
+ right: -2,
38
+ minWidth: 20,
39
+ height: 20,
40
+ padding: '0 6px',
41
+ borderRadius: 10,
42
+ background: '#ef4444',
43
+ color: 'white',
44
+ fontSize: 12,
45
+ fontWeight: 600,
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ border: '2px solid white',
50
+ }, children: unreadCount > 99 ? '99+' : unreadCount }))] }));
51
+ }
@@ -0,0 +1,3 @@
1
+ export { RlseWidget } from './RlseWidget';
2
+ export type { RlseWidgetProps } from './RlseWidget';
3
+ export type { WidgetConfig, Change, WidgetChangesResponse } from './types';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { RlseWidget } from './RlseWidget';
@@ -0,0 +1,11 @@
1
+ export declare function getStorageKey(orgSlug: string): string;
2
+ export declare function getLastVisitKey(orgSlug: string): string;
3
+ export declare function getSeenChangesKey(orgSlug: string): string;
4
+ export declare function getLastVisit(orgSlug: string): number | null;
5
+ export declare function setLastVisit(orgSlug: string): void;
6
+ export declare function getSeenChanges(orgSlug: string): string[];
7
+ export declare function markAllChangesAsSeen(orgSlug: string, changeIds: string[]): void;
8
+ export declare function getUnreadCount(orgSlug: string, changes: {
9
+ _id: string;
10
+ }[]): number;
11
+ export declare function shouldAutoShow(orgSlug: string, autoShowAfter: number): boolean;
@@ -0,0 +1,80 @@
1
+ const STORAGE_KEY_PREFIX = 'rlse_widget_';
2
+ const LAST_VISIT_KEY_SUFFIX = '_last_visit';
3
+ const SEEN_CHANGES_KEY_SUFFIX = '_seen';
4
+ export function getStorageKey(orgSlug) {
5
+ return `${STORAGE_KEY_PREFIX}${orgSlug}`;
6
+ }
7
+ export function getLastVisitKey(orgSlug) {
8
+ return `${getStorageKey(orgSlug)}${LAST_VISIT_KEY_SUFFIX}`;
9
+ }
10
+ export function getSeenChangesKey(orgSlug) {
11
+ return `${getStorageKey(orgSlug)}${SEEN_CHANGES_KEY_SUFFIX}`;
12
+ }
13
+ export function getLastVisit(orgSlug) {
14
+ if (typeof window === 'undefined')
15
+ return null;
16
+ try {
17
+ const value = localStorage.getItem(getLastVisitKey(orgSlug));
18
+ if (!value)
19
+ return null;
20
+ const parsed = parseInt(value, 10);
21
+ return Number.isNaN(parsed) ? null : parsed;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function setLastVisit(orgSlug) {
28
+ if (typeof window === 'undefined')
29
+ return;
30
+ try {
31
+ localStorage.setItem(getLastVisitKey(orgSlug), Date.now().toString());
32
+ }
33
+ catch {
34
+ // Ignore storage errors
35
+ }
36
+ }
37
+ export function getSeenChanges(orgSlug) {
38
+ if (typeof window === 'undefined')
39
+ return [];
40
+ try {
41
+ const value = localStorage.getItem(getSeenChangesKey(orgSlug));
42
+ if (!value)
43
+ return [];
44
+ const parsed = JSON.parse(value);
45
+ // Validate that parsed is an array of strings
46
+ if (!Array.isArray(parsed) ||
47
+ !parsed.every((item) => typeof item === 'string')) {
48
+ return [];
49
+ }
50
+ return parsed;
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ export function markAllChangesAsSeen(orgSlug, changeIds) {
57
+ if (typeof window === 'undefined')
58
+ return;
59
+ try {
60
+ localStorage.setItem(getSeenChangesKey(orgSlug), JSON.stringify(changeIds));
61
+ }
62
+ catch {
63
+ // Ignore storage errors
64
+ }
65
+ }
66
+ export function getUnreadCount(orgSlug, changes) {
67
+ if (typeof window === 'undefined')
68
+ return 0;
69
+ const seen = getSeenChanges(orgSlug);
70
+ return changes.filter((c) => !seen.includes(c._id)).length;
71
+ }
72
+ export function shouldAutoShow(orgSlug, autoShowAfter) {
73
+ if (typeof window === 'undefined')
74
+ return false;
75
+ const lastVisit = getLastVisit(orgSlug);
76
+ if (!lastVisit)
77
+ return true; // First visit
78
+ const daysSinceVisit = (Date.now() - lastVisit) / (1000 * 60 * 60 * 24);
79
+ return daysSinceVisit >= autoShowAfter;
80
+ }
@@ -0,0 +1,54 @@
1
+ export interface WidgetConfig {
2
+ /** Organization slug (required) */
3
+ orgSlug: string;
4
+ /** Optional app slug to filter changes */
5
+ appSlug?: string;
6
+ /** Trigger behavior: 'auto', 'manual', or 'both' */
7
+ trigger?: 'auto' | 'manual' | 'both';
8
+ /** Days since last visit to auto-show (for 'auto' or 'both') */
9
+ autoShowAfter?: number;
10
+ /** Maximum changes to display */
11
+ limit?: number;
12
+ /** Show status badges */
13
+ showStatus?: boolean;
14
+ /** Show creation dates */
15
+ showDates?: boolean;
16
+ /** Button position for manual trigger */
17
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
18
+ /** Theme: 'light', 'dark', or 'auto' */
19
+ theme?: 'light' | 'dark' | 'auto';
20
+ /** Primary accent color (hex) */
21
+ primaryColor?: string;
22
+ /** Button text */
23
+ triggerLabel?: string;
24
+ /** Modal title */
25
+ modalTitle?: string;
26
+ /** Base URL for API (optional, defaults to https://rlse.dev) */
27
+ baseUrl?: string;
28
+ }
29
+ export interface Change {
30
+ _id: string;
31
+ _creationTime: number;
32
+ changeName: string;
33
+ changeSummary: string;
34
+ changeDescription: string;
35
+ currentStatus: string;
36
+ appName?: string;
37
+ appSlug?: string;
38
+ }
39
+ export interface WidgetChangesResponse {
40
+ org: {
41
+ orgName: string;
42
+ orgSlug: string;
43
+ };
44
+ app?: {
45
+ appName: string;
46
+ appSlug: string;
47
+ };
48
+ changes: Change[];
49
+ meta: {
50
+ total: number;
51
+ limit: number;
52
+ };
53
+ }
54
+ export declare const DEFAULT_CONFIG: Partial<WidgetConfig>;
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_CONFIG = {
2
+ trigger: 'manual',
3
+ limit: 10,
4
+ showStatus: true,
5
+ showDates: true,
6
+ position: 'bottom-right',
7
+ theme: 'auto',
8
+ triggerLabel: "What's New",
9
+ modalTitle: 'Release Notes',
10
+ baseUrl: 'https://rlse.dev',
11
+ autoShowAfter: 7,
12
+ };
@@ -0,0 +1,9 @@
1
+ import type { Change } from './types';
2
+ interface UseWidgetDataResult {
3
+ changes: Change[];
4
+ isLoading: boolean;
5
+ error: Error | null;
6
+ refetch: () => Promise<void>;
7
+ }
8
+ export declare function useWidgetData(orgSlug: string, appSlug: string | undefined, limit: number, baseUrl: string): UseWidgetDataResult;
9
+ export {};
@@ -0,0 +1,63 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ export function useWidgetData(orgSlug, appSlug, limit, baseUrl) {
3
+ const [changes, setChanges] = useState([]);
4
+ const [isLoading, setIsLoading] = useState(true);
5
+ const [error, setError] = useState(null);
6
+ const abortControllerRef = useRef(null);
7
+ const fetchChanges = useCallback(async () => {
8
+ // Cancel any in-flight request
9
+ if (abortControllerRef.current) {
10
+ abortControllerRef.current.abort();
11
+ }
12
+ abortControllerRef.current = new AbortController();
13
+ setIsLoading(true);
14
+ setError(null);
15
+ try {
16
+ // Validate baseUrl
17
+ if (!baseUrl || typeof baseUrl !== 'string') {
18
+ throw new Error(`Invalid baseUrl: ${baseUrl}`);
19
+ }
20
+ let url;
21
+ try {
22
+ url = new URL(`${baseUrl}/api/widget/changes`);
23
+ }
24
+ catch {
25
+ throw new Error(`Invalid baseUrl for widget API: ${baseUrl}`);
26
+ }
27
+ url.searchParams.set('orgSlug', orgSlug);
28
+ if (appSlug) {
29
+ url.searchParams.set('appSlug', appSlug);
30
+ }
31
+ url.searchParams.set('limit', limit.toString());
32
+ const response = await fetch(url.toString(), {
33
+ signal: abortControllerRef.current.signal,
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`Failed to fetch changes: ${response.status}`);
37
+ }
38
+ const data = await response.json();
39
+ setChanges(data.changes);
40
+ }
41
+ catch (err) {
42
+ // Don't update state if request was aborted
43
+ if (err instanceof Error && err.name === 'AbortError') {
44
+ return;
45
+ }
46
+ setError(err instanceof Error ? err : new Error('Unknown error'));
47
+ setChanges([]);
48
+ }
49
+ finally {
50
+ setIsLoading(false);
51
+ }
52
+ }, [orgSlug, appSlug, limit, baseUrl]);
53
+ useEffect(() => {
54
+ fetchChanges();
55
+ return () => {
56
+ // Cleanup: abort in-flight request on unmount
57
+ if (abortControllerRef.current) {
58
+ abortControllerRef.current.abort();
59
+ }
60
+ };
61
+ }, [fetchChanges]);
62
+ return { changes, isLoading, error, refetch: fetchChanges };
63
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@rlse/widget",
3
+ "version": "0.1.0",
4
+ "description": "React release notes widget for rlse.dev",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "test": "bun test src",
24
+ "prepublishOnly": "bun run build"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^18.0.0 || ^19.0.0",
28
+ "react-dom": "^18.0.0 || ^19.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "bun-types": "latest",
34
+ "typescript": "^5.0.0"
35
+ },
36
+ "keywords": [
37
+ "release-notes",
38
+ "changelog",
39
+ "widget",
40
+ "react",
41
+ "embed"
42
+ ],
43
+ "license": "MIT"
44
+ }