@papernote/ui 1.10.12 → 1.10.14

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.
Files changed (59) hide show
  1. package/dist/components/AchievementBadge.d.ts +37 -0
  2. package/dist/components/AchievementBadge.d.ts.map +1 -0
  3. package/dist/components/AchievementUnlock.d.ts +31 -0
  4. package/dist/components/AchievementUnlock.d.ts.map +1 -0
  5. package/dist/components/ActivityFeed.d.ts +42 -0
  6. package/dist/components/ActivityFeed.d.ts.map +1 -0
  7. package/dist/components/CollaboratorAvatars.d.ts +33 -0
  8. package/dist/components/CollaboratorAvatars.d.ts.map +1 -0
  9. package/dist/components/InviteCard.d.ts +33 -0
  10. package/dist/components/InviteCard.d.ts.map +1 -0
  11. package/dist/components/MotivationalMessage.d.ts +31 -0
  12. package/dist/components/MotivationalMessage.d.ts.map +1 -0
  13. package/dist/components/PermissionBadge.d.ts +25 -0
  14. package/dist/components/PermissionBadge.d.ts.map +1 -0
  15. package/dist/components/ProgressCelebration.d.ts +30 -0
  16. package/dist/components/ProgressCelebration.d.ts.map +1 -0
  17. package/dist/components/SharedBadge.d.ts +28 -0
  18. package/dist/components/SharedBadge.d.ts.map +1 -0
  19. package/dist/components/StreakBadge.d.ts +27 -0
  20. package/dist/components/StreakBadge.d.ts.map +1 -0
  21. package/dist/components/SuccessCheck.d.ts +27 -0
  22. package/dist/components/SuccessCheck.d.ts.map +1 -0
  23. package/dist/components/index.d.ts +24 -0
  24. package/dist/components/index.d.ts.map +1 -1
  25. package/dist/hooks/useDelighters.d.ts +55 -0
  26. package/dist/hooks/useDelighters.d.ts.map +1 -0
  27. package/dist/index.d.ts +382 -2
  28. package/dist/index.esm.js +1385 -486
  29. package/dist/index.esm.js.map +1 -1
  30. package/dist/index.js +1395 -484
  31. package/dist/index.js.map +1 -1
  32. package/dist/styles.css +201 -0
  33. package/package.json +1 -1
  34. package/src/components/AchievementBadge.stories.tsx +290 -0
  35. package/src/components/AchievementBadge.tsx +196 -0
  36. package/src/components/AchievementUnlock.stories.tsx +345 -0
  37. package/src/components/AchievementUnlock.tsx +157 -0
  38. package/src/components/ActivityFeed.stories.tsx +236 -0
  39. package/src/components/ActivityFeed.tsx +160 -0
  40. package/src/components/Celebration.stories.tsx +3 -3
  41. package/src/components/CollaboratorAvatars.stories.tsx +215 -0
  42. package/src/components/CollaboratorAvatars.tsx +175 -0
  43. package/src/components/InviteCard.stories.tsx +174 -0
  44. package/src/components/InviteCard.tsx +209 -0
  45. package/src/components/MotivationalMessage.stories.tsx +258 -0
  46. package/src/components/MotivationalMessage.tsx +120 -0
  47. package/src/components/PermissionBadge.stories.tsx +208 -0
  48. package/src/components/PermissionBadge.tsx +204 -0
  49. package/src/components/ProgressCelebration.stories.tsx +321 -0
  50. package/src/components/ProgressCelebration.tsx +143 -0
  51. package/src/components/SharedBadge.stories.tsx +210 -0
  52. package/src/components/SharedBadge.tsx +111 -0
  53. package/src/components/StreakBadge.stories.tsx +222 -0
  54. package/src/components/StreakBadge.tsx +132 -0
  55. package/src/components/SuccessCheck.stories.tsx +233 -0
  56. package/src/components/SuccessCheck.tsx +214 -0
  57. package/src/components/Text.stories.tsx +1 -1
  58. package/src/components/index.ts +38 -0
  59. package/src/hooks/useDelighters.ts +133 -0
@@ -0,0 +1,210 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { SharedBadge } from './SharedBadge';
3
+ import Stack from './Stack';
4
+ import Text from './Text';
5
+ import Card, { CardContent, CardHeader, CardTitle } from './Card';
6
+
7
+ const meta: Meta<typeof SharedBadge> = {
8
+ title: 'Collaboration/SharedBadge',
9
+ component: SharedBadge,
10
+ parameters: {
11
+ layout: 'centered',
12
+ docs: {
13
+ description: {
14
+ component: 'Indicator showing content is shared with others. Shows collaborator avatars and count with variant for owned vs shared content.',
15
+ },
16
+ },
17
+ },
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ variant: {
21
+ control: 'select',
22
+ options: ['owned', 'shared'],
23
+ description: 'Whether user owns or was shared with',
24
+ },
25
+ size: {
26
+ control: 'select',
27
+ options: ['sm', 'md', 'lg'],
28
+ description: 'Size of the badge',
29
+ },
30
+ maxDisplay: {
31
+ control: { type: 'number', min: 1, max: 5 },
32
+ description: 'Maximum avatars to display',
33
+ },
34
+ },
35
+ };
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof SharedBadge>;
39
+
40
+ const sampleCollaborators = [
41
+ { name: 'Alice Johnson' },
42
+ { name: 'Bob Smith' },
43
+ { name: 'Carol Williams' },
44
+ ];
45
+
46
+ // Basic examples
47
+ export const Owned: Story = {
48
+ args: {
49
+ sharedWith: sampleCollaborators,
50
+ variant: 'owned',
51
+ },
52
+ };
53
+
54
+ export const SharedWithYou: Story = {
55
+ args: {
56
+ sharedWith: sampleCollaborators,
57
+ variant: 'shared',
58
+ },
59
+ };
60
+
61
+ // Both variants
62
+ export const BothVariants: Story = {
63
+ render: () => (
64
+ <Stack gap="md">
65
+ <Stack direction="horizontal" gap="md" align="center">
66
+ <SharedBadge sharedWith={sampleCollaborators} variant="owned" />
67
+ <Text size="sm" className="text-ink-500">You shared this</Text>
68
+ </Stack>
69
+ <Stack direction="horizontal" gap="md" align="center">
70
+ <SharedBadge sharedWith={sampleCollaborators} variant="shared" />
71
+ <Text size="sm" className="text-ink-500">Shared with you</Text>
72
+ </Stack>
73
+ </Stack>
74
+ ),
75
+ };
76
+
77
+ // All sizes
78
+ export const Sizes: Story = {
79
+ render: () => (
80
+ <Stack gap="md">
81
+ <Stack direction="horizontal" gap="md" align="center">
82
+ <Text size="sm" className="text-ink-400 w-16">Small</Text>
83
+ <SharedBadge sharedWith={sampleCollaborators} variant="owned" size="sm" />
84
+ </Stack>
85
+ <Stack direction="horizontal" gap="md" align="center">
86
+ <Text size="sm" className="text-ink-400 w-16">Medium</Text>
87
+ <SharedBadge sharedWith={sampleCollaborators} variant="owned" size="md" />
88
+ </Stack>
89
+ <Stack direction="horizontal" gap="md" align="center">
90
+ <Text size="sm" className="text-ink-400 w-16">Large</Text>
91
+ <SharedBadge sharedWith={sampleCollaborators} variant="owned" size="lg" />
92
+ </Stack>
93
+ </Stack>
94
+ ),
95
+ };
96
+
97
+ // Different collaborator counts
98
+ export const CollaboratorCounts: Story = {
99
+ render: () => (
100
+ <Stack gap="md">
101
+ <Stack direction="horizontal" gap="md" align="center">
102
+ <SharedBadge sharedWith={[{ name: 'Alice' }]} variant="owned" />
103
+ <Text size="sm" className="text-ink-500">1 person</Text>
104
+ </Stack>
105
+ <Stack direction="horizontal" gap="md" align="center">
106
+ <SharedBadge sharedWith={sampleCollaborators.slice(0, 2)} variant="owned" />
107
+ <Text size="sm" className="text-ink-500">2 people</Text>
108
+ </Stack>
109
+ <Stack direction="horizontal" gap="md" align="center">
110
+ <SharedBadge sharedWith={sampleCollaborators} variant="owned" />
111
+ <Text size="sm" className="text-ink-500">3 people</Text>
112
+ </Stack>
113
+ <Stack direction="horizontal" gap="md" align="center">
114
+ <SharedBadge
115
+ sharedWith={[...sampleCollaborators, { name: 'David' }, { name: 'Eve' }]}
116
+ variant="owned"
117
+ maxDisplay={3}
118
+ />
119
+ <Text size="sm" className="text-ink-500">5 people (max 3)</Text>
120
+ </Stack>
121
+ </Stack>
122
+ ),
123
+ };
124
+
125
+ // Clickable
126
+ export const Clickable: Story = {
127
+ render: () => (
128
+ <SharedBadge
129
+ sharedWith={sampleCollaborators}
130
+ variant="owned"
131
+ onClick={() => alert('Show sharing settings')}
132
+ />
133
+ ),
134
+ };
135
+
136
+ // In context - Document card
137
+ export const DocumentCard: Story = {
138
+ render: () => (
139
+ <Card className="w-80">
140
+ <CardHeader>
141
+ <Stack direction="horizontal" justify="between" align="start">
142
+ <CardTitle>Q4 Budget</CardTitle>
143
+ <SharedBadge
144
+ sharedWith={sampleCollaborators.slice(0, 2)}
145
+ variant="owned"
146
+ size="sm"
147
+ />
148
+ </Stack>
149
+ </CardHeader>
150
+ <CardContent>
151
+ <Text size="sm" className="text-ink-500">
152
+ Quarterly budget planning document shared with the finance team.
153
+ </Text>
154
+ </CardContent>
155
+ </Card>
156
+ ),
157
+ };
158
+
159
+ // In context - File list
160
+ export const FileList: Story = {
161
+ render: () => (
162
+ <div className="w-96 border border-paper-200 rounded-lg overflow-hidden">
163
+ {[
164
+ { name: 'Family Budget.xlsx', variant: 'owned' as const, sharedWith: [{ name: 'Jane' }] },
165
+ { name: 'Tax Documents', variant: 'shared' as const, sharedWith: [{ name: 'Accountant' }] },
166
+ { name: 'Savings Goals.pdf', variant: 'owned' as const, sharedWith: sampleCollaborators },
167
+ ].map((file, index) => (
168
+ <div
169
+ key={file.name}
170
+ className={`p-3 flex items-center justify-between ${
171
+ index > 0 ? 'border-t border-paper-200' : ''
172
+ }`}
173
+ >
174
+ <Text size="sm" weight="medium">{file.name}</Text>
175
+ <SharedBadge
176
+ sharedWith={file.sharedWith}
177
+ variant={file.variant}
178
+ size="sm"
179
+ />
180
+ </div>
181
+ ))}
182
+ </div>
183
+ ),
184
+ };
185
+
186
+ // With avatars
187
+ export const WithAvatars: Story = {
188
+ args: {
189
+ sharedWith: [
190
+ { name: 'Alice Johnson', avatar: 'https://i.pravatar.cc/100?u=alice' },
191
+ { name: 'Bob Smith', avatar: 'https://i.pravatar.cc/100?u=bob' },
192
+ ],
193
+ variant: 'owned',
194
+ },
195
+ };
196
+
197
+ // Empty state (renders nothing)
198
+ export const EmptyState: Story = {
199
+ render: () => (
200
+ <Stack gap="md">
201
+ <Text size="sm" className="text-ink-500">
202
+ SharedBadge with no collaborators renders nothing:
203
+ </Text>
204
+ <div className="p-4 border border-dashed border-paper-300 rounded-lg min-h-12 flex items-center justify-center">
205
+ <SharedBadge sharedWith={[]} variant="owned" />
206
+ <Text size="xs" className="text-ink-400">(empty)</Text>
207
+ </div>
208
+ </Stack>
209
+ ),
210
+ };
@@ -0,0 +1,111 @@
1
+ import { Users, User } from 'lucide-react';
2
+ import { CollaboratorAvatars, Collaborator } from './CollaboratorAvatars';
3
+
4
+ export interface SharedBadgeProps {
5
+ /** Array of people this content is shared with */
6
+ sharedWith: Collaborator[];
7
+ /** Whether the user owns this content or was shared with them */
8
+ variant: 'owned' | 'shared';
9
+ /** Size of the badge */
10
+ size?: 'sm' | 'md' | 'lg';
11
+ /** Maximum avatars to display */
12
+ maxDisplay?: number;
13
+ /** Click handler */
14
+ onClick?: () => void;
15
+ /** Additional CSS classes */
16
+ className?: string;
17
+ }
18
+
19
+ const sizeStyles = {
20
+ sm: {
21
+ badge: 'px-2 py-1 gap-1.5 text-xs',
22
+ icon: 'w-3.5 h-3.5',
23
+ },
24
+ md: {
25
+ badge: 'px-2.5 py-1.5 gap-2 text-sm',
26
+ icon: 'w-4 h-4',
27
+ },
28
+ lg: {
29
+ badge: 'px-3 py-2 gap-2.5 text-base',
30
+ icon: 'w-5 h-5',
31
+ },
32
+ };
33
+
34
+ const variantConfig = {
35
+ owned: {
36
+ bg: 'bg-accent-50',
37
+ border: 'border-accent-200',
38
+ text: 'text-accent-700',
39
+ icon: Users,
40
+ label: 'Shared by you',
41
+ },
42
+ shared: {
43
+ bg: 'bg-primary-50',
44
+ border: 'border-primary-200',
45
+ text: 'text-primary-700',
46
+ icon: User,
47
+ label: 'Shared with you',
48
+ },
49
+ };
50
+
51
+ /**
52
+ * SharedBadge - Indicator showing content is shared with others.
53
+ *
54
+ * Features:
55
+ * - Two variants: owned (you shared it) or shared (shared with you)
56
+ * - Shows collaborator avatars
57
+ * - Displays count of collaborators
58
+ * - Three sizes: sm, md, lg
59
+ * - Optional click handler
60
+ */
61
+ export function SharedBadge({
62
+ sharedWith,
63
+ variant,
64
+ size = 'md',
65
+ maxDisplay = 3,
66
+ onClick,
67
+ className = '',
68
+ }: SharedBadgeProps) {
69
+ const styles = sizeStyles[size];
70
+ const config = variantConfig[variant];
71
+ const Icon = config.icon;
72
+
73
+ const count = sharedWith.length;
74
+
75
+ if (count === 0) {
76
+ return null;
77
+ }
78
+
79
+ return (
80
+ <div
81
+ className={`
82
+ inline-flex items-center
83
+ ${styles.badge}
84
+ ${config.bg}
85
+ border ${config.border}
86
+ rounded-full
87
+ ${onClick ? 'cursor-pointer hover:opacity-90 transition-opacity' : ''}
88
+ ${className}
89
+ `}
90
+ onClick={onClick}
91
+ role={onClick ? 'button' : 'status'}
92
+ aria-label={`${config.label} with ${count} ${count === 1 ? 'person' : 'people'}`}
93
+ >
94
+ <Icon className={`${styles.icon} ${config.text}`} />
95
+
96
+ {count <= maxDisplay ? (
97
+ <CollaboratorAvatars
98
+ collaborators={sharedWith}
99
+ max={maxDisplay}
100
+ size={size === 'lg' ? 'md' : 'sm'}
101
+ />
102
+ ) : (
103
+ <span className={`font-medium ${config.text}`}>
104
+ {count} {count === 1 ? 'person' : 'people'}
105
+ </span>
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ export default SharedBadge;
@@ -0,0 +1,222 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { StreakBadge } from './StreakBadge';
3
+ import Stack from './Stack';
4
+ import Text from './Text';
5
+ import Card, { CardContent, CardHeader, CardTitle } from './Card';
6
+
7
+ const meta: Meta<typeof StreakBadge> = {
8
+ title: 'Feedback/StreakBadge',
9
+ component: StreakBadge,
10
+ parameters: {
11
+ layout: 'centered',
12
+ docs: {
13
+ description: {
14
+ component: 'Display streak achievements with a flame icon. The color intensity increases with streak length, and includes optional "New Record" indicator with animation.',
15
+ },
16
+ },
17
+ },
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ count: {
21
+ control: { type: 'number', min: 1, max: 365, step: 1 },
22
+ description: 'The streak count',
23
+ },
24
+ unit: {
25
+ control: 'select',
26
+ options: ['days', 'weeks', 'months'],
27
+ description: 'The unit of time for the streak',
28
+ },
29
+ type: {
30
+ control: 'text',
31
+ description: 'Optional type descriptor',
32
+ },
33
+ isNewRecord: {
34
+ control: 'boolean',
35
+ description: 'Whether this is a new personal record',
36
+ },
37
+ size: {
38
+ control: 'select',
39
+ options: ['sm', 'md', 'lg'],
40
+ description: 'Size of the badge',
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof StreakBadge>;
47
+
48
+ // Basic example
49
+ export const Default: Story = {
50
+ args: {
51
+ count: 12,
52
+ unit: 'days',
53
+ },
54
+ };
55
+
56
+ // All sizes
57
+ export const Sizes: Story = {
58
+ render: () => (
59
+ <Stack direction="horizontal" gap="md" align="center">
60
+ <Stack align="center" gap="xs">
61
+ <StreakBadge count={7} unit="days" size="sm" />
62
+ <Text size="xs" className="text-ink-400">Small</Text>
63
+ </Stack>
64
+ <Stack align="center" gap="xs">
65
+ <StreakBadge count={7} unit="days" size="md" />
66
+ <Text size="xs" className="text-ink-400">Medium</Text>
67
+ </Stack>
68
+ <Stack align="center" gap="xs">
69
+ <StreakBadge count={7} unit="days" size="lg" />
70
+ <Text size="xs" className="text-ink-400">Large</Text>
71
+ </Stack>
72
+ </Stack>
73
+ ),
74
+ };
75
+
76
+ // Color intensity progression
77
+ export const ColorProgression: Story = {
78
+ render: () => (
79
+ <Stack gap="md">
80
+ <Text weight="semibold" className="text-ink-600">Color intensity increases with streak length:</Text>
81
+ <Stack direction="horizontal" gap="md" wrap>
82
+ <Stack align="center" gap="xs">
83
+ <StreakBadge count={3} unit="days" />
84
+ <Text size="xs" className="text-ink-400">Starting</Text>
85
+ </Stack>
86
+ <Stack align="center" gap="xs">
87
+ <StreakBadge count={7} unit="days" />
88
+ <Text size="xs" className="text-ink-400">1 week</Text>
89
+ </Stack>
90
+ <Stack align="center" gap="xs">
91
+ <StreakBadge count={30} unit="days" />
92
+ <Text size="xs" className="text-ink-400">1 month</Text>
93
+ </Stack>
94
+ <Stack align="center" gap="xs">
95
+ <StreakBadge count={100} unit="days" />
96
+ <Text size="xs" className="text-ink-400">100 days</Text>
97
+ </Stack>
98
+ <Stack align="center" gap="xs">
99
+ <StreakBadge count={365} unit="days" />
100
+ <Text size="xs" className="text-ink-400">1 year</Text>
101
+ </Stack>
102
+ </Stack>
103
+ </Stack>
104
+ ),
105
+ };
106
+
107
+ // With type descriptor
108
+ export const WithType: Story = {
109
+ render: () => (
110
+ <Stack gap="md" direction="horizontal" wrap>
111
+ <StreakBadge count={12} unit="days" type="budget" />
112
+ <StreakBadge count={8} unit="weeks" type="savings" />
113
+ <StreakBadge count={3} unit="months" type="tracking" />
114
+ </Stack>
115
+ ),
116
+ };
117
+
118
+ // New record
119
+ export const NewRecord: Story = {
120
+ args: {
121
+ count: 15,
122
+ unit: 'days',
123
+ type: 'budget',
124
+ isNewRecord: true,
125
+ },
126
+ };
127
+
128
+ // Different units
129
+ export const DifferentUnits: Story = {
130
+ render: () => (
131
+ <Stack gap="md" direction="horizontal" wrap>
132
+ <StreakBadge count={1} unit="days" />
133
+ <StreakBadge count={5} unit="days" />
134
+ <StreakBadge count={1} unit="weeks" />
135
+ <StreakBadge count={4} unit="weeks" />
136
+ <StreakBadge count={1} unit="months" />
137
+ <StreakBadge count={6} unit="months" />
138
+ </Stack>
139
+ ),
140
+ };
141
+
142
+ // In context - Profile card
143
+ export const ProfileContext: Story = {
144
+ render: () => (
145
+ <Card className="w-80">
146
+ <CardHeader>
147
+ <CardTitle>Your Achievements</CardTitle>
148
+ </CardHeader>
149
+ <CardContent>
150
+ <Stack gap="md">
151
+ <Stack direction="horizontal" justify="between" align="center">
152
+ <Text size="sm" className="text-ink-600">Budget Streak</Text>
153
+ <StreakBadge count={12} unit="days" type="budget" size="sm" isNewRecord />
154
+ </Stack>
155
+ <Stack direction="horizontal" justify="between" align="center">
156
+ <Text size="sm" className="text-ink-600">Savings Streak</Text>
157
+ <StreakBadge count={8} unit="weeks" type="savings" size="sm" />
158
+ </Stack>
159
+ <Stack direction="horizontal" justify="between" align="center">
160
+ <Text size="sm" className="text-ink-600">Tracking Streak</Text>
161
+ <StreakBadge count={45} unit="days" type="tracking" size="sm" />
162
+ </Stack>
163
+ </Stack>
164
+ </CardContent>
165
+ </Card>
166
+ ),
167
+ };
168
+
169
+ // Dashboard highlight
170
+ export const DashboardHighlight: Story = {
171
+ render: () => (
172
+ <div className="bg-gradient-to-br from-warning-50 to-warning-100 p-6 rounded-xl border border-warning-200 w-80">
173
+ <Stack align="center" gap="md">
174
+ <StreakBadge count={30} unit="days" type="budget" size="lg" isNewRecord />
175
+ <Stack align="center" gap="xs">
176
+ <Text weight="semibold" className="text-ink-800">
177
+ 30-Day Milestone!
178
+ </Text>
179
+ <Text size="sm" className="text-ink-600 text-center">
180
+ You've stayed under budget for a full month.
181
+ That's your longest streak yet!
182
+ </Text>
183
+ </Stack>
184
+ </Stack>
185
+ </div>
186
+ ),
187
+ };
188
+
189
+ // Comparison
190
+ export const Comparison: Story = {
191
+ render: () => (
192
+ <Stack gap="lg">
193
+ <Stack direction="horizontal" gap="lg" align="center">
194
+ <Stack gap="xs" align="center" className="w-32">
195
+ <Text size="xs" className="text-ink-400 uppercase tracking-wide">Last Month</Text>
196
+ <StreakBadge count={5} unit="days" />
197
+ </Stack>
198
+ <Stack gap="xs" align="center" className="w-32">
199
+ <Text size="xs" className="text-ink-400 uppercase tracking-wide">This Month</Text>
200
+ <StreakBadge count={18} unit="days" isNewRecord />
201
+ </Stack>
202
+ </Stack>
203
+ <div className="text-center">
204
+ <Text size="sm" className="text-success-600">
205
+ +260% improvement! Keep it up!
206
+ </Text>
207
+ </div>
208
+ </Stack>
209
+ ),
210
+ };
211
+
212
+ // Inline usage
213
+ export const InlineUsage: Story = {
214
+ render: () => (
215
+ <div className="bg-white p-4 rounded-lg shadow-card border border-paper-200 w-96">
216
+ <Text size="sm" className="text-ink-600 leading-relaxed">
217
+ Great job! You're on a <StreakBadge count={12} unit="days" size="sm" />
218
+ budget streak. That's your best this year!
219
+ </Text>
220
+ </div>
221
+ ),
222
+ };
@@ -0,0 +1,132 @@
1
+
2
+ import { Flame, Sparkles } from 'lucide-react';
3
+
4
+ export interface StreakBadgeProps {
5
+ /** The streak count */
6
+ count: number;
7
+ /** The unit of time for the streak */
8
+ unit: 'days' | 'weeks' | 'months';
9
+ /** Optional type descriptor (e.g., "budget", "savings") */
10
+ type?: string;
11
+ /** Whether this is a new personal record */
12
+ isNewRecord?: boolean;
13
+ /** Size of the badge */
14
+ size?: 'sm' | 'md' | 'lg';
15
+ /** Additional CSS classes */
16
+ className?: string;
17
+ }
18
+
19
+ const sizeStyles = {
20
+ sm: {
21
+ container: 'px-2 py-1 gap-1',
22
+ icon: 'w-3.5 h-3.5',
23
+ count: 'text-sm font-bold',
24
+ unit: 'text-xs',
25
+ record: 'text-2xs px-1.5 py-0.5',
26
+ },
27
+ md: {
28
+ container: 'px-3 py-1.5 gap-1.5',
29
+ icon: 'w-4 h-4',
30
+ count: 'text-base font-bold',
31
+ unit: 'text-sm',
32
+ record: 'text-xs px-2 py-0.5',
33
+ },
34
+ lg: {
35
+ container: 'px-4 py-2 gap-2',
36
+ icon: 'w-5 h-5',
37
+ count: 'text-lg font-bold',
38
+ unit: 'text-base',
39
+ record: 'text-sm px-2 py-1',
40
+ },
41
+ };
42
+
43
+ // Color intensity increases with streak length
44
+ function getFlameColor(count: number): string {
45
+ if (count >= 100) return 'text-error-500'; // Red hot
46
+ if (count >= 30) return 'text-warning-500'; // Orange
47
+ if (count >= 7) return 'text-warning-400'; // Light orange
48
+ return 'text-warning-300'; // Warm yellow
49
+ }
50
+
51
+ function getBackgroundColor(count: number): string {
52
+ if (count >= 100) return 'bg-error-50';
53
+ if (count >= 30) return 'bg-warning-50';
54
+ if (count >= 7) return 'bg-warning-50/70';
55
+ return 'bg-warning-50/50';
56
+ }
57
+
58
+ /**
59
+ * StreakBadge - Display streak achievements with a flame icon.
60
+ *
61
+ * Features:
62
+ * - Flame icon with color intensity based on streak length
63
+ * - Optional type descriptor (e.g., "12-day budget streak")
64
+ * - New record indicator with sparkle animation
65
+ * - Three sizes: sm, md, lg
66
+ * - Compact inline display
67
+ */
68
+ export function StreakBadge({
69
+ count,
70
+ unit,
71
+ type,
72
+ isNewRecord = false,
73
+ size = 'md',
74
+ className = '',
75
+ }: StreakBadgeProps) {
76
+ const styles = sizeStyles[size];
77
+ const flameColor = getFlameColor(count);
78
+ const bgColor = getBackgroundColor(count);
79
+
80
+ const unitLabel = count === 1 ? unit.slice(0, -1) : unit; // day vs days
81
+
82
+ return (
83
+ <div
84
+ className={`
85
+ inline-flex items-center
86
+ ${bgColor}
87
+ rounded-full
88
+ ${styles.container}
89
+ ${className}
90
+ `}
91
+ role="status"
92
+ aria-label={`${count} ${unitLabel}${type ? ` ${type}` : ''} streak${isNewRecord ? ' - new record!' : ''}`}
93
+ >
94
+ {/* Flame icon */}
95
+ <Flame
96
+ className={`${styles.icon} ${flameColor} ${count >= 30 ? 'animate-pulse-slow' : ''}`}
97
+ />
98
+
99
+ {/* Count */}
100
+ <span className={`${styles.count} text-ink-800`}>
101
+ {count}
102
+ </span>
103
+
104
+ {/* Unit and type */}
105
+ <span className={`${styles.unit} text-ink-500`}>
106
+ {unitLabel}
107
+ {type && (
108
+ <span className="text-ink-400"> {type}</span>
109
+ )}
110
+ </span>
111
+
112
+ {/* New record indicator */}
113
+ {isNewRecord && (
114
+ <span
115
+ className={`
116
+ inline-flex items-center gap-0.5
117
+ bg-success-500 text-white
118
+ rounded-full
119
+ ${styles.record}
120
+ font-medium
121
+ animate-bounce-subtle
122
+ `}
123
+ >
124
+ <Sparkles className="w-3 h-3" />
125
+ New!
126
+ </span>
127
+ )}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ export default StreakBadge;