featuredrop 1.0.1 → 1.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 +263 -195
- package/dist/index.cjs +14 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -2
- package/dist/index.d.ts +43 -2
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +1077 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +324 -5
- package/dist/react.d.ts +324 -5
- package/dist/react.js +1075 -5
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,37 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">featuredrop</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>"New" badges that actually expire.</strong>
|
|
5
|
+
<br />
|
|
6
|
+
Lightweight feature discovery for SaaS sidebars, dashboards, and nav menus.
|
|
7
|
+
</p>
|
|
8
|
+
</p>
|
|
2
9
|
|
|
3
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://www.npmjs.com/package/featuredrop"><img src="https://img.shields.io/npm/v/featuredrop?color=f59e0b&label=npm" alt="npm version"></a>
|
|
12
|
+
<a href="https://bundlephobia.com/package/featuredrop"><img src="https://img.shields.io/bundlephobia/minzip/featuredrop?color=22c55e&label=size" alt="bundle size"></a>
|
|
13
|
+
<a href="https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml"><img src="https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
14
|
+
<a href="https://github.com/GLINCKER/featuredrop/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/featuredrop?color=blue" alt="license"></a>
|
|
15
|
+
</p>
|
|
4
16
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
17
|
+
<p align="center">
|
|
18
|
+
<a href="#quick-start">Quick Start</a> •
|
|
19
|
+
<a href="#components">Components</a> •
|
|
20
|
+
<a href="#react">React</a> •
|
|
21
|
+
<a href="https://github.com/GLINCKER/featuredrop/blob/main/docs/API.md">API Docs</a> •
|
|
22
|
+
<a href="https://github.com/GLINCKER/featuredrop/blob/main/docs/ARCHITECTURE.md">Architecture</a>
|
|
23
|
+
</p>
|
|
9
24
|
|
|
10
25
|
---
|
|
11
26
|
|
|
12
|
-
##
|
|
27
|
+
## The Problem
|
|
13
28
|
|
|
14
|
-
Every SaaS
|
|
29
|
+
Every SaaS ships features. Users miss them. You need "New" badges on sidebar items, but:
|
|
15
30
|
|
|
16
|
-
**
|
|
31
|
+
- **LaunchDarkly / PostHog** — Feature flags, not discovery badges. Overkill.
|
|
32
|
+
- **Beamer / Headway** — External widget, vendor lock-in, $59-299/mo.
|
|
33
|
+
- **Joyride / Shepherd.js** — Product tours, not persistent badges.
|
|
34
|
+
- **DIY** — You build it, forget expiry, badges stay forever. Users stop noticing.
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
- Badges auto-expire based on time windows
|
|
20
|
-
- Users can dismiss individually or "mark all as seen"
|
|
21
|
-
- Works with any framework — React bindings included
|
|
22
|
-
- Zero dependencies, < 2 kB minzipped
|
|
36
|
+
## The Solution
|
|
23
37
|
|
|
24
|
-
|
|
38
|
+
**featuredrop** is a free, self-hosted alternative to Beamer, Headway, and AnnounceKit. Zero deps, < 10 kB, headless-first.
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
```
|
|
41
|
+
npm install featuredrop # 0 dependencies, < 2 kB core
|
|
42
|
+
```
|
|
27
43
|
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
```
|
|
45
|
+
┌─────────────────────────────────────────┐
|
|
46
|
+
│ ☰ My SaaS App 🔔 │
|
|
47
|
+
├──────────┬──────────────────────────────┤
|
|
48
|
+
│ │ │
|
|
49
|
+
│ Dashboard│ Welcome back, Sarah! │
|
|
50
|
+
│ │ │
|
|
51
|
+
│ Journal ●│ ┌─────────────────────┐ │
|
|
52
|
+
│ │ │ What's New (2) │ │
|
|
53
|
+
│ Analytics│ │ │ │
|
|
54
|
+
│ NEW │ │ ★ AI Journal │ │
|
|
55
|
+
│ │ │ Track decisions │ │
|
|
56
|
+
│ Billing │ │ with AI insights. │ │
|
|
57
|
+
│ │ │ [Try it →] │ │
|
|
58
|
+
│ Settings │ │ │ │
|
|
59
|
+
│ │ │ ★ Analytics v2 │ │
|
|
60
|
+
│ │ │ Real-time charts │ │
|
|
61
|
+
│ │ │ and CSV export. │ │
|
|
62
|
+
│ │ │ │ │
|
|
63
|
+
│ │ │ [Mark all as read] │ │
|
|
64
|
+
│ │ └─────────────────────┘ │
|
|
65
|
+
│ │ │
|
|
66
|
+
└──────────┴──────────────────────────────┘
|
|
67
|
+
|
|
68
|
+
● = dot badge NEW = pill badge (2) = count badge
|
|
32
69
|
```
|
|
33
70
|
|
|
34
|
-
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
**1. Define features** (just an array of objects):
|
|
35
74
|
|
|
36
75
|
```ts
|
|
37
76
|
import { createManifest } from 'featuredrop'
|
|
@@ -40,254 +79,283 @@ export const FEATURES = createManifest([
|
|
|
40
79
|
{
|
|
41
80
|
id: 'ai-journal',
|
|
42
81
|
label: 'AI Decision Journal',
|
|
43
|
-
description: 'Track decisions with AI-powered insights',
|
|
82
|
+
description: 'Track decisions with AI-powered insights.',
|
|
44
83
|
releasedAt: '2026-02-20T00:00:00Z',
|
|
45
84
|
showNewUntil: '2026-03-20T00:00:00Z',
|
|
46
85
|
sidebarKey: '/journal',
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
id: 'analytics-v2',
|
|
51
|
-
label: 'Analytics Dashboard v2',
|
|
52
|
-
releasedAt: '2026-02-25T00:00:00Z',
|
|
53
|
-
showNewUntil: '2026-03-25T00:00:00Z',
|
|
54
|
-
sidebarKey: '/analytics',
|
|
86
|
+
type: 'feature',
|
|
87
|
+
priority: 'critical',
|
|
88
|
+
cta: { label: 'Try it', url: '/journal' },
|
|
55
89
|
},
|
|
56
90
|
])
|
|
57
91
|
```
|
|
58
92
|
|
|
59
|
-
|
|
93
|
+
**2. Create a storage adapter:**
|
|
60
94
|
|
|
61
95
|
```ts
|
|
62
96
|
import { LocalStorageAdapter } from 'featuredrop'
|
|
63
97
|
|
|
64
98
|
const storage = new LocalStorageAdapter({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Optional: callback when user clicks "Mark all as seen"
|
|
68
|
-
onDismissAll: async (now) => {
|
|
69
|
-
await api.updateUser({ featuresSeenAt: now.toISOString() })
|
|
70
|
-
},
|
|
99
|
+
watermark: user.featuresSeenAt, // from your server
|
|
100
|
+
onDismissAll: (now) => api.markFeaturesSeen(now), // optional server sync
|
|
71
101
|
})
|
|
72
102
|
```
|
|
73
103
|
|
|
74
|
-
|
|
104
|
+
**3. Check what's new:**
|
|
75
105
|
|
|
76
106
|
```ts
|
|
77
107
|
import { getNewFeatures, hasNewFeature } from 'featuredrop'
|
|
78
108
|
|
|
79
|
-
// Get all new features
|
|
80
109
|
const newFeatures = getNewFeatures(FEATURES, storage)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Check a specific sidebar item
|
|
84
|
-
if (hasNewFeature(FEATURES, '/journal', storage)) {
|
|
85
|
-
showBadge('/journal')
|
|
86
|
-
}
|
|
110
|
+
hasNewFeature(FEATURES, '/journal', storage) // true/false
|
|
87
111
|
```
|
|
88
112
|
|
|
89
|
-
|
|
113
|
+
Works with **any framework**. Zero React dependency for vanilla use.
|
|
90
114
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
```
|
|
115
|
+
## Components
|
|
116
|
+
|
|
117
|
+
Everything you'd expect from Beamer or Headway — but free, self-hosted, and headless-first.
|
|
95
118
|
|
|
96
|
-
###
|
|
119
|
+
### Changelog Widget
|
|
120
|
+
|
|
121
|
+
The #1 feature people install these tools for. Trigger button with unread count badge, slide-out panel with rich changelog feed.
|
|
97
122
|
|
|
98
123
|
```tsx
|
|
99
|
-
import {
|
|
100
|
-
import { LocalStorageAdapter } from 'featuredrop'
|
|
124
|
+
import { ChangelogWidget } from 'featuredrop/react'
|
|
101
125
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
onDismissAll: (now) => api.markFeaturesSeen(now),
|
|
105
|
-
})
|
|
126
|
+
// Default: slide-out panel with all features
|
|
127
|
+
<ChangelogWidget />
|
|
106
128
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
|
|
129
|
+
// Or modal / popover variant
|
|
130
|
+
<ChangelogWidget variant="modal" title="Release Notes" />
|
|
131
|
+
|
|
132
|
+
// Fully headless
|
|
133
|
+
<ChangelogWidget>
|
|
134
|
+
{({ isOpen, toggle, features, count, dismissAll }) => (
|
|
135
|
+
<YourCustomUI />
|
|
136
|
+
)}
|
|
137
|
+
</ChangelogWidget>
|
|
114
138
|
```
|
|
115
139
|
|
|
116
|
-
###
|
|
140
|
+
### Spotlight Beacon
|
|
141
|
+
|
|
142
|
+
Pulsing beacon that attaches to any DOM element. Click to see feature tooltip.
|
|
117
143
|
|
|
118
144
|
```tsx
|
|
119
|
-
import {
|
|
145
|
+
import { Spotlight } from 'featuredrop/react'
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
148
|
+
<button ref={ref}>Analytics</button>
|
|
149
|
+
<Spotlight featureId="analytics-v2" targetRef={ref} />
|
|
123
150
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{label}
|
|
127
|
-
{isNew && <NewBadge />}
|
|
128
|
-
</a>
|
|
129
|
-
)
|
|
130
|
-
}
|
|
151
|
+
// Or with CSS selector
|
|
152
|
+
<Spotlight featureId="analytics-v2" targetSelector="#analytics-btn" />
|
|
131
153
|
```
|
|
132
154
|
|
|
133
|
-
###
|
|
155
|
+
### Announcement Banner
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
import { useFeatureDrop } from 'featuredrop/react'
|
|
157
|
+
Top-of-page or inline banner for major announcements. Auto-expires like badges.
|
|
137
158
|
|
|
138
|
-
|
|
139
|
-
|
|
159
|
+
```tsx
|
|
160
|
+
import { Banner } from 'featuredrop/react'
|
|
140
161
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{newFeatures.map(f => (
|
|
145
|
-
<div key={f.id}>
|
|
146
|
-
<h3>{f.label}</h3>
|
|
147
|
-
<p>{f.description}</p>
|
|
148
|
-
</div>
|
|
149
|
-
))}
|
|
150
|
-
<button onClick={dismissAll}>Mark all as seen</button>
|
|
151
|
-
</div>
|
|
152
|
-
)
|
|
153
|
-
}
|
|
162
|
+
<Banner featureId="v2-launch" variant="announcement" />
|
|
163
|
+
<Banner featureId="breaking-change" variant="warning" />
|
|
164
|
+
<Banner featureId="security-fix" variant="info" position="fixed" />
|
|
154
165
|
```
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
### Toast Notifications
|
|
168
|
+
|
|
169
|
+
Brief popup notifications for new features. Auto-dismiss, stackable, configurable position.
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import { Toast } from 'featuredrop/react'
|
|
173
|
+
|
|
174
|
+
<Toast position="bottom-right" maxVisible={3} />
|
|
157
175
|
|
|
176
|
+
// Specific features only
|
|
177
|
+
<Toast featureIds={["ai-journal"]} autoDismissMs={5000} />
|
|
158
178
|
```
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
│ 1. Not dismissed? ✓ │
|
|
171
|
-
│ 2. Before showNewUntil? ✓ │
|
|
172
|
-
│ 3. Released after watermark? ✓ │
|
|
173
|
-
│ │
|
|
174
|
-
│ → Show "New" badge │
|
|
175
|
-
└─────────────────────────────────────┘
|
|
179
|
+
|
|
180
|
+
### NewBadge
|
|
181
|
+
|
|
182
|
+
Headless badge component with variants. Zero CSS framework dependency.
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { NewBadge } from 'featuredrop/react'
|
|
186
|
+
|
|
187
|
+
<NewBadge /> // "New" pill
|
|
188
|
+
<NewBadge variant="dot" /> // Pulsing dot
|
|
189
|
+
<NewBadge variant="count" count={3} /> // Count badge
|
|
176
190
|
```
|
|
177
191
|
|
|
178
|
-
|
|
192
|
+
### Tab Title Notification
|
|
179
193
|
|
|
180
|
-
|
|
181
|
-
2. **Expired?** — Is the current time past `showNewUntil`? (automatic, no user action needed)
|
|
182
|
-
3. **After watermark?** — Was the feature released after the user's "features seen at" timestamp? (server-side, cross-device)
|
|
194
|
+
Updates the browser tab title with unread count. Restores when all read.
|
|
183
195
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
- Returning users only see features released since their last visit
|
|
187
|
-
- Individual dismissals are instant (localStorage, no server call)
|
|
188
|
-
- "Mark all as seen" syncs across devices (server watermark update)
|
|
196
|
+
```tsx
|
|
197
|
+
import { useTabNotification } from 'featuredrop/react'
|
|
189
198
|
|
|
190
|
-
|
|
199
|
+
useTabNotification() // "(3) My App"
|
|
200
|
+
useTabNotification({ template: "[{count} new] {title}", flash: true })
|
|
201
|
+
```
|
|
191
202
|
|
|
192
|
-
|
|
203
|
+
## React
|
|
193
204
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
| `getNewFeatures(manifest, storage, now?)` | Get all currently new features |
|
|
198
|
-
| `getNewFeatureCount(manifest, storage, now?)` | Get count of new features |
|
|
199
|
-
| `hasNewFeature(manifest, sidebarKey, storage, now?)` | Check if a sidebar key has new features |
|
|
205
|
+
```bash
|
|
206
|
+
npm install featuredrop react # react is an optional peer dep
|
|
207
|
+
```
|
|
200
208
|
|
|
201
|
-
|
|
209
|
+
**Wrap your app:**
|
|
202
210
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
| `createManifest(entries)` | Create a frozen, typed manifest |
|
|
206
|
-
| `getFeatureById(manifest, id)` | Find a feature by ID |
|
|
207
|
-
| `getNewFeaturesByCategory(manifest, category, storage, now?)` | Filter new features by category |
|
|
211
|
+
```tsx
|
|
212
|
+
import { FeatureDropProvider } from 'featuredrop/react'
|
|
208
213
|
|
|
209
|
-
|
|
214
|
+
<FeatureDropProvider manifest={FEATURES} storage={storage}>
|
|
215
|
+
<App />
|
|
216
|
+
</FeatureDropProvider>
|
|
217
|
+
```
|
|
210
218
|
|
|
211
|
-
|
|
212
|
-
|---------|-------------|
|
|
213
|
-
| `LocalStorageAdapter` | Browser localStorage + server watermark |
|
|
214
|
-
| `MemoryAdapter` | In-memory (testing, SSR) |
|
|
219
|
+
**Add badges to your sidebar:**
|
|
215
220
|
|
|
216
|
-
|
|
221
|
+
```tsx
|
|
222
|
+
import { useNewFeature, NewBadge } from 'featuredrop/react'
|
|
217
223
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
### NewBadge Styling
|
|
227
|
-
|
|
228
|
-
Zero CSS framework dependency. Style via CSS custom properties:
|
|
229
|
-
|
|
230
|
-
```css
|
|
231
|
-
/* In your global CSS or CSS-in-JS */
|
|
232
|
-
[data-featuredrop] {
|
|
233
|
-
--featuredrop-color: #b45309;
|
|
234
|
-
--featuredrop-bg: rgba(245, 158, 11, 0.15);
|
|
235
|
-
--featuredrop-font-size: 10px;
|
|
236
|
-
--featuredrop-dot-size: 8px;
|
|
237
|
-
--featuredrop-glow: rgba(245, 158, 11, 0.6);
|
|
238
|
-
--featuredrop-count-size: 18px;
|
|
239
|
-
--featuredrop-count-color: white;
|
|
240
|
-
--featuredrop-count-bg: #f59e0b;
|
|
224
|
+
function SidebarItem({ path, label }: { path: string; label: string }) {
|
|
225
|
+
const { isNew, dismiss } = useNewFeature(path)
|
|
226
|
+
return (
|
|
227
|
+
<a href={path} onClick={() => isNew && dismiss()}>
|
|
228
|
+
{label}
|
|
229
|
+
{isNew && <NewBadge />}
|
|
230
|
+
</a>
|
|
231
|
+
)
|
|
241
232
|
}
|
|
242
233
|
```
|
|
243
234
|
|
|
244
|
-
|
|
235
|
+
**Or drop in the full changelog widget:**
|
|
245
236
|
|
|
246
|
-
|
|
237
|
+
```tsx
|
|
238
|
+
import { ChangelogWidget } from 'featuredrop/react'
|
|
247
239
|
|
|
248
|
-
|
|
249
|
-
|
|
240
|
+
<ChangelogWidget variant="panel" />
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Hooks & Components:**
|
|
244
|
+
|
|
245
|
+
| Export | What it does |
|
|
246
|
+
|--------|-------------|
|
|
247
|
+
| `useFeatureDrop()` | Full context: features, count, dismiss, dismissAll |
|
|
248
|
+
| `useNewFeature(key)` | Single nav item: `{ isNew, feature, dismiss }` |
|
|
249
|
+
| `useNewCount()` | Just the badge count |
|
|
250
|
+
| `useTabNotification()` | Updates browser tab title with count |
|
|
251
|
+
| `<NewBadge />` | Headless badge: `pill`, `dot`, or `count` variant |
|
|
252
|
+
| `<ChangelogWidget />` | Full changelog feed with trigger button |
|
|
253
|
+
| `<Spotlight />` | Pulsing beacon attached to DOM elements |
|
|
254
|
+
| `<Banner />` | Announcement banner with variants |
|
|
255
|
+
| `<Toast />` | Stackable toast notifications |
|
|
256
|
+
|
|
257
|
+
**Analytics integration:**
|
|
250
258
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
259
|
+
```tsx
|
|
260
|
+
<FeatureDropProvider
|
|
261
|
+
manifest={FEATURES}
|
|
262
|
+
storage={storage}
|
|
263
|
+
analytics={{
|
|
264
|
+
onFeatureSeen: (f) => posthog.capture('feature_seen', { id: f.id }),
|
|
265
|
+
onFeatureDismissed: (f) => posthog.capture('feature_dismissed', { id: f.id }),
|
|
266
|
+
onFeatureClicked: (f) => posthog.capture('feature_clicked', { id: f.id }),
|
|
267
|
+
onWidgetOpened: () => posthog.capture('changelog_opened'),
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
```
|
|
255
271
|
|
|
256
|
-
|
|
257
|
-
return new Set(this.cache.get('dismissed') ?? [])
|
|
258
|
-
}
|
|
272
|
+
All components accept an optional `analytics` prop for component-level tracking too.
|
|
259
273
|
|
|
260
|
-
|
|
261
|
-
this.cache.append('dismissed', id)
|
|
262
|
-
}
|
|
274
|
+
## How It Works
|
|
263
275
|
|
|
264
|
-
async dismissAll(now: Date): Promise<void> {
|
|
265
|
-
await this.cache.set('watermark', now.toISOString())
|
|
266
|
-
await this.cache.delete('dismissed')
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
276
|
```
|
|
277
|
+
Manifest (static) Storage (runtime)
|
|
278
|
+
┌───────────────────┐ ┌──────────────────────┐
|
|
279
|
+
│ releasedAt: Feb 20 │ │ watermark ← server │
|
|
280
|
+
│ showNewUntil: Mar 20│ │ dismissed ← localStorage│
|
|
281
|
+
└────────┬──────────┘ └──────────┬───────────┘
|
|
282
|
+
│ │
|
|
283
|
+
└──────────┐ ┌────────────────┘
|
|
284
|
+
▼ ▼
|
|
285
|
+
┌───────────────┐
|
|
286
|
+
│ isNew() │
|
|
287
|
+
│ │
|
|
288
|
+
│ !dismissed │
|
|
289
|
+
│ !expired │
|
|
290
|
+
│ afterWatermark│
|
|
291
|
+
│ afterPublishAt│
|
|
292
|
+
└───────┬───────┘
|
|
293
|
+
│
|
|
294
|
+
true / false
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
New users see everything (no watermark). Returning users only see features shipped since their last visit. Individual dismissals are instant (localStorage). "Mark all seen" syncs across devices (one server write).
|
|
298
|
+
|
|
299
|
+
**Scheduled publishing**: Set `publishAt` to hide entries until a specific date — ship code now, reveal later.
|
|
300
|
+
|
|
301
|
+
**Priority sorting**: Critical features surface first in widgets and toasts. Priority levels: `critical`, `normal`, `low`.
|
|
302
|
+
|
|
303
|
+
**Entry types**: `feature`, `improvement`, `fix`, `breaking` — each with default icon/color in built-in components.
|
|
304
|
+
|
|
305
|
+
Read the full [Architecture doc](docs/ARCHITECTURE.md) for cross-device sync flow and custom adapter patterns.
|
|
270
306
|
|
|
271
307
|
## Comparison
|
|
272
308
|
|
|
273
|
-
|
|
|
274
|
-
|
|
275
|
-
|
|
|
276
|
-
|
|
|
277
|
-
|
|
|
278
|
-
|
|
|
279
|
-
|
|
|
280
|
-
|
|
|
281
|
-
|
|
|
282
|
-
|
|
|
283
|
-
|
|
|
309
|
+
| | featuredrop | Beamer | Headway | AnnounceKit | Canny |
|
|
310
|
+
|---|:---:|:---:|:---:|:---:|:---:|
|
|
311
|
+
| **Price** | **Free** | $59-399/mo | $49-249/mo | $79-299/mo | $79+ |
|
|
312
|
+
| Auto-expiring badges | Yes | - | - | - | - |
|
|
313
|
+
| Changelog widget | Yes | Yes | Yes | Yes | Yes |
|
|
314
|
+
| Spotlight/beacon | Yes | - | - | - | - |
|
|
315
|
+
| Toast notifications | Yes | - | - | - | - |
|
|
316
|
+
| Announcement banner | Yes | - | - | - | - |
|
|
317
|
+
| Tab title notification | Yes | - | - | - | - |
|
|
318
|
+
| Zero dependencies | Yes | - | - | - | - |
|
|
319
|
+
| Framework agnostic | Yes | - | - | - | - |
|
|
320
|
+
| React bindings | Yes | - | - | - | - |
|
|
321
|
+
| Headless mode | Yes | - | - | - | - |
|
|
322
|
+
| Cross-device sync | Yes | Yes | Yes | Yes | Yes |
|
|
323
|
+
| Per-feature dismiss | Yes | - | - | - | - |
|
|
324
|
+
| Scheduled publishing | Yes | Yes | Yes | Yes | - |
|
|
325
|
+
| Priority levels | Yes | - | - | - | - |
|
|
326
|
+
| Analytics callbacks | Yes | Built-in | Built-in | Built-in | Built-in |
|
|
327
|
+
| < 10 kB minzipped | Yes | - | - | - | - |
|
|
328
|
+
| Self-hosted | Yes | - | - | - | - |
|
|
329
|
+
| Open source | Yes | - | - | - | - |
|
|
330
|
+
|
|
331
|
+
## Framework Support
|
|
332
|
+
|
|
333
|
+
| Framework | Status | Import |
|
|
334
|
+
|-----------|--------|--------|
|
|
335
|
+
| React / Next.js | Stable | `featuredrop/react` |
|
|
336
|
+
| Vanilla JS | Stable | `featuredrop` |
|
|
337
|
+
| Vue 3 | Planned | `featuredrop/vue` |
|
|
338
|
+
| Svelte 5 | Planned | `featuredrop/svelte` |
|
|
339
|
+
|
|
340
|
+
## Documentation
|
|
341
|
+
|
|
342
|
+
- [API Reference](docs/API.md) — All functions, adapters, hooks, components
|
|
343
|
+
- [Architecture](docs/ARCHITECTURE.md) — Three-check algorithm, cross-device sync, custom adapters
|
|
344
|
+
- [Next.js Example](examples/nextjs/) — Full App Router integration
|
|
345
|
+
- [Vanilla Example](examples/vanilla/) — Plain HTML, zero build step
|
|
346
|
+
|
|
347
|
+
## Contributing
|
|
348
|
+
|
|
349
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit conventions, and how releases work.
|
|
284
350
|
|
|
285
351
|
## License
|
|
286
352
|
|
|
287
|
-
MIT
|
|
353
|
+
MIT © [Glincker](https://glincker.com)
|
|
288
354
|
|
|
289
355
|
---
|
|
290
356
|
|
|
291
357
|
<p align="center">
|
|
292
|
-
<
|
|
358
|
+
<sub>Built and battle-tested at <a href="https://askverdict.ai">AskVerdict</a>.</sub>
|
|
359
|
+
<br />
|
|
360
|
+
<strong>A <a href="https://glincker.com">GLINCKER</a> open source project.</strong>
|
|
293
361
|
</p>
|
package/dist/index.cjs
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
function isNew(feature, watermark, dismissedIds, now = /* @__PURE__ */ new Date()) {
|
|
5
5
|
if (dismissedIds.has(feature.id)) return false;
|
|
6
6
|
const nowMs = now.getTime();
|
|
7
|
+
if (feature.publishAt) {
|
|
8
|
+
const publishMs = new Date(feature.publishAt).getTime();
|
|
9
|
+
if (nowMs < publishMs) return false;
|
|
10
|
+
}
|
|
7
11
|
const showUntilMs = new Date(feature.showNewUntil).getTime();
|
|
8
12
|
if (nowMs >= showUntilMs) return false;
|
|
9
13
|
if (watermark) {
|
|
@@ -28,6 +32,15 @@ function hasNewFeature(manifest, sidebarKey, storage, now = /* @__PURE__ */ new
|
|
|
28
32
|
(f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now)
|
|
29
33
|
);
|
|
30
34
|
}
|
|
35
|
+
function getNewFeaturesSorted(manifest, storage, now = /* @__PURE__ */ new Date()) {
|
|
36
|
+
const priorityOrder = { critical: 0, normal: 1, low: 2 };
|
|
37
|
+
return getNewFeatures(manifest, storage, now).sort((a, b) => {
|
|
38
|
+
const pa = priorityOrder[a.priority ?? "normal"];
|
|
39
|
+
const pb = priorityOrder[b.priority ?? "normal"];
|
|
40
|
+
if (pa !== pb) return pa - pb;
|
|
41
|
+
return new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
31
44
|
|
|
32
45
|
// src/helpers.ts
|
|
33
46
|
function createManifest(entries) {
|
|
@@ -127,6 +140,7 @@ exports.getFeatureById = getFeatureById;
|
|
|
127
140
|
exports.getNewFeatureCount = getNewFeatureCount;
|
|
128
141
|
exports.getNewFeatures = getNewFeatures;
|
|
129
142
|
exports.getNewFeaturesByCategory = getNewFeaturesByCategory;
|
|
143
|
+
exports.getNewFeaturesSorted = getNewFeaturesSorted;
|
|
130
144
|
exports.hasNewFeature = hasNewFeature;
|
|
131
145
|
exports.isNew = isNew;
|
|
132
146
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core.ts","../src/helpers.ts","../src/adapters/local-storage.ts","../src/adapters/memory.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\n\n/**\n * Check if a single feature should show as \"new\".\n *\n * A feature is \"new\" when ALL of these are true:\n * 1. Current time is before `showNewUntil`\n * 2. Feature was released after the watermark (or no watermark exists)\n * 3. Feature has not been individually dismissed\n */\nexport function isNew(\n feature: FeatureEntry,\n watermark: string | null,\n dismissedIds: ReadonlySet<string>,\n now: Date = new Date(),\n): boolean {\n // Already dismissed by the user on this device\n if (dismissedIds.has(feature.id)) return false;\n\n const nowMs = now.getTime();\n const showUntilMs = new Date(feature.showNewUntil).getTime();\n\n // Past the display window\n if (nowMs >= showUntilMs) return false;\n\n // If there's a watermark, feature must have been released after it\n if (watermark) {\n const watermarkMs = new Date(watermark).getTime();\n const releasedMs = new Date(feature.releasedAt).getTime();\n if (releasedMs <= watermarkMs) return false;\n }\n\n return true;\n}\n\n/**\n * Get all features that are currently \"new\" for this user.\n */\nexport function getNewFeatures(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));\n}\n\n/**\n * Get the count of new features.\n */\nexport function getNewFeatureCount(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): number {\n return getNewFeatures(manifest, storage, now).length;\n}\n\n/**\n * Check if a specific sidebar key has a new feature.\n */\nexport function hasNewFeature(\n manifest: FeatureManifest,\n sidebarKey: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): boolean {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.some(\n (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\nimport { isNew } from \"./core\";\n\n/**\n * Create a frozen feature manifest from an array of entries.\n * Ensures the manifest is immutable at runtime.\n */\nexport function createManifest(\n entries: FeatureEntry[],\n): FeatureManifest {\n return Object.freeze([...entries]);\n}\n\n/**\n * Find a feature by its ID in the manifest.\n * Returns `undefined` if not found.\n */\nexport function getFeatureById(\n manifest: FeatureManifest,\n id: string,\n): FeatureEntry | undefined {\n return manifest.find((f) => f.id === id);\n}\n\n/**\n * Get all new features in a specific category.\n */\nexport function getNewFeaturesByCategory(\n manifest: FeatureManifest,\n category: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter(\n (f) => f.category === category && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { StorageAdapter } from \"../types\";\n\nexport interface LocalStorageAdapterOptions {\n /** Key prefix for localStorage entries. Default: \"featuredrop\" */\n prefix?: string;\n /** Server-side watermark (ISO string). Typically from user profile. */\n watermark?: string | null;\n /** Callback when dismissAll is called. Use for server-side watermark updates. */\n onDismissAll?: (now: Date) => Promise<void>;\n}\n\nconst DISMISSED_SUFFIX = \":dismissed\";\n\n/**\n * localStorage-based storage adapter.\n *\n * Architecture:\n * - **Watermark** comes from the server (passed at construction time)\n * - **Per-feature dismissals** are stored in localStorage (zero server writes)\n * - **dismissAll()** optionally calls a server callback, then clears localStorage\n *\n * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.\n */\nexport class LocalStorageAdapter implements StorageAdapter {\n private readonly prefix: string;\n private readonly watermarkValue: string | null;\n private readonly onDismissAllCallback?: (now: Date) => Promise<void>;\n private readonly dismissedKey: string;\n\n constructor(options: LocalStorageAdapterOptions = {}) {\n this.prefix = options.prefix ?? \"featuredrop\";\n this.watermarkValue = options.watermark ?? null;\n this.onDismissAllCallback = options.onDismissAll;\n this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;\n }\n\n getWatermark(): string | null {\n return this.watermarkValue;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n try {\n if (typeof window === \"undefined\") return new Set();\n const raw = localStorage.getItem(this.dismissedKey);\n if (!raw) return new Set();\n const parsed: unknown = JSON.parse(raw);\n if (Array.isArray(parsed)) return new Set(parsed as string[]);\n return new Set();\n } catch {\n return new Set();\n }\n }\n\n dismiss(id: string): void {\n try {\n if (typeof window === \"undefined\") return;\n const raw = localStorage.getItem(this.dismissedKey);\n const existing: string[] = raw ? (JSON.parse(raw) as string[]) : [];\n if (!existing.includes(id)) {\n existing.push(id);\n localStorage.setItem(this.dismissedKey, JSON.stringify(existing));\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n }\n\n async dismissAll(now: Date): Promise<void> {\n try {\n if (typeof window !== \"undefined\") {\n localStorage.removeItem(this.dismissedKey);\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n\n if (this.onDismissAllCallback) {\n await this.onDismissAllCallback(now);\n }\n }\n}\n","import type { StorageAdapter } from \"../types\";\n\n/**\n * In-memory storage adapter.\n *\n * Useful for:\n * - Testing (no side effects)\n * - Server-side rendering (no `window`/`localStorage`)\n * - Environments without persistent storage\n */\nexport class MemoryAdapter implements StorageAdapter {\n private watermark: string | null;\n private dismissed: Set<string>;\n\n constructor(options: { watermark?: string | null } = {}) {\n this.watermark = options.watermark ?? null;\n this.dismissed = new Set();\n }\n\n getWatermark(): string | null {\n return this.watermark;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n return this.dismissed;\n }\n\n dismiss(id: string): void {\n this.dismissed.add(id);\n }\n\n async dismissAll(now: Date): Promise<void> {\n this.watermark = now.toISOString();\n this.dismissed.clear();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/core.ts","../src/helpers.ts","../src/adapters/local-storage.ts","../src/adapters/memory.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\n\n/**\n * Check if a single feature should show as \"new\".\n *\n * A feature is \"new\" when ALL of these are true:\n * 1. Current time is before `showNewUntil`\n * 2. Feature was released after the watermark (or no watermark exists)\n * 3. Feature has not been individually dismissed\n * 4. If `publishAt` is set, current time must be after it (scheduled publishing)\n */\nexport function isNew(\n feature: FeatureEntry,\n watermark: string | null,\n dismissedIds: ReadonlySet<string>,\n now: Date = new Date(),\n): boolean {\n // Already dismissed by the user on this device\n if (dismissedIds.has(feature.id)) return false;\n\n const nowMs = now.getTime();\n\n // Scheduled publishing — hidden until publishAt\n if (feature.publishAt) {\n const publishMs = new Date(feature.publishAt).getTime();\n if (nowMs < publishMs) return false;\n }\n\n const showUntilMs = new Date(feature.showNewUntil).getTime();\n\n // Past the display window\n if (nowMs >= showUntilMs) return false;\n\n // If there's a watermark, feature must have been released after it\n if (watermark) {\n const watermarkMs = new Date(watermark).getTime();\n const releasedMs = new Date(feature.releasedAt).getTime();\n if (releasedMs <= watermarkMs) return false;\n }\n\n return true;\n}\n\n/**\n * Get all features that are currently \"new\" for this user.\n */\nexport function getNewFeatures(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));\n}\n\n/**\n * Get the count of new features.\n */\nexport function getNewFeatureCount(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): number {\n return getNewFeatures(manifest, storage, now).length;\n}\n\n/**\n * Check if a specific sidebar key has a new feature.\n */\nexport function hasNewFeature(\n manifest: FeatureManifest,\n sidebarKey: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): boolean {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.some(\n (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now),\n );\n}\n\n/**\n * Get all features sorted by priority (critical first) then by release date (newest first).\n */\nexport function getNewFeaturesSorted(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const priorityOrder = { critical: 0, normal: 1, low: 2 };\n return getNewFeatures(manifest, storage, now).sort((a, b) => {\n const pa = priorityOrder[a.priority ?? \"normal\"];\n const pb = priorityOrder[b.priority ?? \"normal\"];\n if (pa !== pb) return pa - pb;\n return new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime();\n });\n}\n","import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\nimport { isNew } from \"./core\";\n\n/**\n * Create a frozen feature manifest from an array of entries.\n * Ensures the manifest is immutable at runtime.\n */\nexport function createManifest(\n entries: FeatureEntry[],\n): FeatureManifest {\n return Object.freeze([...entries]);\n}\n\n/**\n * Find a feature by its ID in the manifest.\n * Returns `undefined` if not found.\n */\nexport function getFeatureById(\n manifest: FeatureManifest,\n id: string,\n): FeatureEntry | undefined {\n return manifest.find((f) => f.id === id);\n}\n\n/**\n * Get all new features in a specific category.\n */\nexport function getNewFeaturesByCategory(\n manifest: FeatureManifest,\n category: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter(\n (f) => f.category === category && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { StorageAdapter } from \"../types\";\n\nexport interface LocalStorageAdapterOptions {\n /** Key prefix for localStorage entries. Default: \"featuredrop\" */\n prefix?: string;\n /** Server-side watermark (ISO string). Typically from user profile. */\n watermark?: string | null;\n /** Callback when dismissAll is called. Use for server-side watermark updates. */\n onDismissAll?: (now: Date) => Promise<void>;\n}\n\nconst DISMISSED_SUFFIX = \":dismissed\";\n\n/**\n * localStorage-based storage adapter.\n *\n * Architecture:\n * - **Watermark** comes from the server (passed at construction time)\n * - **Per-feature dismissals** are stored in localStorage (zero server writes)\n * - **dismissAll()** optionally calls a server callback, then clears localStorage\n *\n * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.\n */\nexport class LocalStorageAdapter implements StorageAdapter {\n private readonly prefix: string;\n private readonly watermarkValue: string | null;\n private readonly onDismissAllCallback?: (now: Date) => Promise<void>;\n private readonly dismissedKey: string;\n\n constructor(options: LocalStorageAdapterOptions = {}) {\n this.prefix = options.prefix ?? \"featuredrop\";\n this.watermarkValue = options.watermark ?? null;\n this.onDismissAllCallback = options.onDismissAll;\n this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;\n }\n\n getWatermark(): string | null {\n return this.watermarkValue;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n try {\n if (typeof window === \"undefined\") return new Set();\n const raw = localStorage.getItem(this.dismissedKey);\n if (!raw) return new Set();\n const parsed: unknown = JSON.parse(raw);\n if (Array.isArray(parsed)) return new Set(parsed as string[]);\n return new Set();\n } catch {\n return new Set();\n }\n }\n\n dismiss(id: string): void {\n try {\n if (typeof window === \"undefined\") return;\n const raw = localStorage.getItem(this.dismissedKey);\n const existing: string[] = raw ? (JSON.parse(raw) as string[]) : [];\n if (!existing.includes(id)) {\n existing.push(id);\n localStorage.setItem(this.dismissedKey, JSON.stringify(existing));\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n }\n\n async dismissAll(now: Date): Promise<void> {\n try {\n if (typeof window !== \"undefined\") {\n localStorage.removeItem(this.dismissedKey);\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n\n if (this.onDismissAllCallback) {\n await this.onDismissAllCallback(now);\n }\n }\n}\n","import type { StorageAdapter } from \"../types\";\n\n/**\n * In-memory storage adapter.\n *\n * Useful for:\n * - Testing (no side effects)\n * - Server-side rendering (no `window`/`localStorage`)\n * - Environments without persistent storage\n */\nexport class MemoryAdapter implements StorageAdapter {\n private watermark: string | null;\n private dismissed: Set<string>;\n\n constructor(options: { watermark?: string | null } = {}) {\n this.watermark = options.watermark ?? null;\n this.dismissed = new Set();\n }\n\n getWatermark(): string | null {\n return this.watermark;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n return this.dismissed;\n }\n\n dismiss(id: string): void {\n this.dismissed.add(id);\n }\n\n async dismissAll(now: Date): Promise<void> {\n this.watermark = now.toISOString();\n this.dismissed.clear();\n }\n}\n"]}
|