@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.
- package/dist/components/AchievementBadge.d.ts +37 -0
- package/dist/components/AchievementBadge.d.ts.map +1 -0
- package/dist/components/AchievementUnlock.d.ts +31 -0
- package/dist/components/AchievementUnlock.d.ts.map +1 -0
- package/dist/components/ActivityFeed.d.ts +42 -0
- package/dist/components/ActivityFeed.d.ts.map +1 -0
- package/dist/components/CollaboratorAvatars.d.ts +33 -0
- package/dist/components/CollaboratorAvatars.d.ts.map +1 -0
- package/dist/components/InviteCard.d.ts +33 -0
- package/dist/components/InviteCard.d.ts.map +1 -0
- package/dist/components/MotivationalMessage.d.ts +31 -0
- package/dist/components/MotivationalMessage.d.ts.map +1 -0
- package/dist/components/PermissionBadge.d.ts +25 -0
- package/dist/components/PermissionBadge.d.ts.map +1 -0
- package/dist/components/ProgressCelebration.d.ts +30 -0
- package/dist/components/ProgressCelebration.d.ts.map +1 -0
- package/dist/components/SharedBadge.d.ts +28 -0
- package/dist/components/SharedBadge.d.ts.map +1 -0
- package/dist/components/StreakBadge.d.ts +27 -0
- package/dist/components/StreakBadge.d.ts.map +1 -0
- package/dist/components/SuccessCheck.d.ts +27 -0
- package/dist/components/SuccessCheck.d.ts.map +1 -0
- package/dist/components/index.d.ts +24 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/hooks/useDelighters.d.ts +55 -0
- package/dist/hooks/useDelighters.d.ts.map +1 -0
- package/dist/index.d.ts +382 -2
- package/dist/index.esm.js +1385 -486
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1395 -484
- package/dist/index.js.map +1 -1
- package/dist/styles.css +201 -0
- package/package.json +1 -1
- package/src/components/AchievementBadge.stories.tsx +290 -0
- package/src/components/AchievementBadge.tsx +196 -0
- package/src/components/AchievementUnlock.stories.tsx +345 -0
- package/src/components/AchievementUnlock.tsx +157 -0
- package/src/components/ActivityFeed.stories.tsx +236 -0
- package/src/components/ActivityFeed.tsx +160 -0
- package/src/components/Celebration.stories.tsx +3 -3
- package/src/components/CollaboratorAvatars.stories.tsx +215 -0
- package/src/components/CollaboratorAvatars.tsx +175 -0
- package/src/components/InviteCard.stories.tsx +174 -0
- package/src/components/InviteCard.tsx +209 -0
- package/src/components/MotivationalMessage.stories.tsx +258 -0
- package/src/components/MotivationalMessage.tsx +120 -0
- package/src/components/PermissionBadge.stories.tsx +208 -0
- package/src/components/PermissionBadge.tsx +204 -0
- package/src/components/ProgressCelebration.stories.tsx +321 -0
- package/src/components/ProgressCelebration.tsx +143 -0
- package/src/components/SharedBadge.stories.tsx +210 -0
- package/src/components/SharedBadge.tsx +111 -0
- package/src/components/StreakBadge.stories.tsx +222 -0
- package/src/components/StreakBadge.tsx +132 -0
- package/src/components/SuccessCheck.stories.tsx +233 -0
- package/src/components/SuccessCheck.tsx +214 -0
- package/src/components/Text.stories.tsx +1 -1
- package/src/components/index.ts +38 -0
- 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;
|