@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,175 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import Tooltip from './Tooltip';
|
|
3
|
+
|
|
4
|
+
export interface Collaborator {
|
|
5
|
+
/** Collaborator's name */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Optional avatar URL */
|
|
8
|
+
avatar?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CollaboratorAvatarsProps {
|
|
12
|
+
/** Array of collaborators to display */
|
|
13
|
+
collaborators: Collaborator[];
|
|
14
|
+
/** Maximum number of avatars to display before showing overflow */
|
|
15
|
+
max?: number;
|
|
16
|
+
/** Size of the avatars */
|
|
17
|
+
size?: 'sm' | 'md' | 'lg';
|
|
18
|
+
/** Click handler for the avatar stack */
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
/** Additional CSS classes */
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sizeStyles = {
|
|
25
|
+
sm: {
|
|
26
|
+
container: 'w-6 h-6 text-xs',
|
|
27
|
+
overlap: '-ml-2',
|
|
28
|
+
border: 'ring-2',
|
|
29
|
+
},
|
|
30
|
+
md: {
|
|
31
|
+
container: 'w-8 h-8 text-sm',
|
|
32
|
+
overlap: '-ml-2.5',
|
|
33
|
+
border: 'ring-2',
|
|
34
|
+
},
|
|
35
|
+
lg: {
|
|
36
|
+
container: 'w-10 h-10 text-base',
|
|
37
|
+
overlap: '-ml-3',
|
|
38
|
+
border: 'ring-2',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Generate consistent color from name
|
|
43
|
+
function getAvatarColor(name: string): string {
|
|
44
|
+
const colors = [
|
|
45
|
+
'bg-accent-500',
|
|
46
|
+
'bg-success-500',
|
|
47
|
+
'bg-warning-500',
|
|
48
|
+
'bg-error-500',
|
|
49
|
+
'bg-primary-500',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
let hash = 0;
|
|
53
|
+
for (let i = 0; i < name.length; i++) {
|
|
54
|
+
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return colors[Math.abs(hash) % colors.length];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Get initials from name
|
|
61
|
+
function getInitials(name: string): string {
|
|
62
|
+
const parts = name.trim().split(/\s+/);
|
|
63
|
+
if (parts.length >= 2) {
|
|
64
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
65
|
+
}
|
|
66
|
+
return name.slice(0, 2).toUpperCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* CollaboratorAvatars - Stacked avatar display for collaborators.
|
|
71
|
+
*
|
|
72
|
+
* Features:
|
|
73
|
+
* - Displays avatars in an overlapping stack
|
|
74
|
+
* - Shows overflow indicator (+N) when exceeding max
|
|
75
|
+
* - Falls back to initials when no avatar image
|
|
76
|
+
* - Consistent color generation from names
|
|
77
|
+
* - Tooltip showing collaborator names
|
|
78
|
+
* - Three sizes: sm, md, lg
|
|
79
|
+
* - Optional click handler
|
|
80
|
+
*/
|
|
81
|
+
export const CollaboratorAvatars = forwardRef<HTMLDivElement, CollaboratorAvatarsProps>(
|
|
82
|
+
function CollaboratorAvatars(
|
|
83
|
+
{
|
|
84
|
+
collaborators,
|
|
85
|
+
max = 3,
|
|
86
|
+
size = 'md',
|
|
87
|
+
onClick,
|
|
88
|
+
className = '',
|
|
89
|
+
},
|
|
90
|
+
ref
|
|
91
|
+
) {
|
|
92
|
+
const styles = sizeStyles[size];
|
|
93
|
+
const visible = collaborators.slice(0, max);
|
|
94
|
+
const overflow = collaborators.length - max;
|
|
95
|
+
const hasOverflow = overflow > 0;
|
|
96
|
+
|
|
97
|
+
const allNames = collaborators.map((c) => c.name).join(', ');
|
|
98
|
+
const tooltipContent = (
|
|
99
|
+
<div className="text-center">
|
|
100
|
+
<div className="font-medium mb-1">Collaborators</div>
|
|
101
|
+
<div className="text-ink-300">{allNames}</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const avatarStack = (
|
|
106
|
+
<div
|
|
107
|
+
ref={ref}
|
|
108
|
+
className={`
|
|
109
|
+
inline-flex items-center
|
|
110
|
+
${onClick ? 'cursor-pointer hover:opacity-90 transition-opacity' : ''}
|
|
111
|
+
${className}
|
|
112
|
+
`}
|
|
113
|
+
onClick={onClick}
|
|
114
|
+
role={onClick ? 'button' : 'group'}
|
|
115
|
+
aria-label={`${collaborators.length} collaborator${collaborators.length !== 1 ? 's' : ''}`}
|
|
116
|
+
>
|
|
117
|
+
{visible.map((collaborator, index) => (
|
|
118
|
+
<div
|
|
119
|
+
key={`${collaborator.name}-${index}`}
|
|
120
|
+
className={`
|
|
121
|
+
${styles.container}
|
|
122
|
+
${index > 0 ? styles.overlap : ''}
|
|
123
|
+
${styles.border}
|
|
124
|
+
ring-white
|
|
125
|
+
rounded-full
|
|
126
|
+
flex items-center justify-center
|
|
127
|
+
overflow-hidden
|
|
128
|
+
${collaborator.avatar ? '' : getAvatarColor(collaborator.name)}
|
|
129
|
+
text-white font-medium
|
|
130
|
+
shrink-0
|
|
131
|
+
`}
|
|
132
|
+
style={{ zIndex: visible.length - index }}
|
|
133
|
+
>
|
|
134
|
+
{collaborator.avatar ? (
|
|
135
|
+
<img
|
|
136
|
+
src={collaborator.avatar}
|
|
137
|
+
alt={collaborator.name}
|
|
138
|
+
className="w-full h-full object-cover"
|
|
139
|
+
/>
|
|
140
|
+
) : (
|
|
141
|
+
<span>{getInitials(collaborator.name)}</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
|
|
146
|
+
{hasOverflow && (
|
|
147
|
+
<div
|
|
148
|
+
className={`
|
|
149
|
+
${styles.container}
|
|
150
|
+
${styles.overlap}
|
|
151
|
+
${styles.border}
|
|
152
|
+
ring-white
|
|
153
|
+
rounded-full
|
|
154
|
+
flex items-center justify-center
|
|
155
|
+
bg-paper-300
|
|
156
|
+
text-ink-600 font-medium
|
|
157
|
+
shrink-0
|
|
158
|
+
`}
|
|
159
|
+
style={{ zIndex: 0 }}
|
|
160
|
+
>
|
|
161
|
+
+{overflow}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Tooltip content={tooltipContent} position="top">
|
|
169
|
+
{avatarStack}
|
|
170
|
+
</Tooltip>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
export default CollaboratorAvatars;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { InviteCard, PendingInvite } from './InviteCard';
|
|
4
|
+
import Stack from './Stack';
|
|
5
|
+
import Text from './Text';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof InviteCard> = {
|
|
8
|
+
title: 'Collaboration/InviteCard',
|
|
9
|
+
component: InviteCard,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component: 'Card for inviting collaborators via email. Features email input with validation, pending invitations list, and cancel functionality.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
tags: ['autodocs'],
|
|
19
|
+
argTypes: {
|
|
20
|
+
loading: {
|
|
21
|
+
control: 'boolean',
|
|
22
|
+
description: 'Whether an invitation is being sent',
|
|
23
|
+
},
|
|
24
|
+
maxPending: {
|
|
25
|
+
control: { type: 'number', min: 1, max: 10 },
|
|
26
|
+
description: 'Maximum pending invites to display',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof InviteCard>;
|
|
33
|
+
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const samplePending: PendingInvite[] = [
|
|
36
|
+
{ email: 'alice@example.com', sentAt: new Date(now.getTime() - 5 * 60000) },
|
|
37
|
+
{ email: 'bob@example.com', sentAt: new Date(now.getTime() - 2 * 3600000) },
|
|
38
|
+
{ email: 'carol@example.com', sentAt: new Date(now.getTime() - 24 * 3600000) },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Basic example
|
|
42
|
+
export const Default: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
onInvite: (email) => alert(`Invited: ${email}`),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// With pending invites
|
|
49
|
+
export const WithPending: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
onInvite: (email) => alert(`Invited: ${email}`),
|
|
52
|
+
pending: samplePending,
|
|
53
|
+
onCancelInvite: (email) => alert(`Cancelled: ${email}`),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Loading state
|
|
58
|
+
export const Loading: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
onInvite: () => {},
|
|
61
|
+
loading: true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Interactive demo
|
|
66
|
+
export const Interactive: Story = {
|
|
67
|
+
render: function InteractiveDemo() {
|
|
68
|
+
const [pending, setPending] = useState<PendingInvite[]>([]);
|
|
69
|
+
const [loading, setLoading] = useState(false);
|
|
70
|
+
|
|
71
|
+
const handleInvite = (email: string) => {
|
|
72
|
+
setLoading(true);
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
setPending((prev) => [
|
|
75
|
+
{ email, sentAt: new Date() },
|
|
76
|
+
...prev,
|
|
77
|
+
]);
|
|
78
|
+
setLoading(false);
|
|
79
|
+
}, 1000);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleCancel = (email: string) => {
|
|
83
|
+
setPending((prev) => prev.filter((p) => p.email !== email));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="w-96">
|
|
88
|
+
<InviteCard
|
|
89
|
+
onInvite={handleInvite}
|
|
90
|
+
pending={pending}
|
|
91
|
+
loading={loading}
|
|
92
|
+
onCancelInvite={handleCancel}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Many pending invites
|
|
100
|
+
export const ManyPending: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
onInvite: (email) => alert(`Invited: ${email}`),
|
|
103
|
+
pending: [
|
|
104
|
+
...samplePending,
|
|
105
|
+
{ email: 'david@example.com', sentAt: new Date() },
|
|
106
|
+
{ email: 'eve@example.com', sentAt: new Date() },
|
|
107
|
+
{ email: 'frank@example.com', sentAt: new Date() },
|
|
108
|
+
{ email: 'grace@example.com', sentAt: new Date() },
|
|
109
|
+
],
|
|
110
|
+
maxPending: 3,
|
|
111
|
+
onCancelInvite: (email) => alert(`Cancelled: ${email}`),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Without cancel functionality
|
|
116
|
+
export const WithoutCancel: Story = {
|
|
117
|
+
args: {
|
|
118
|
+
onInvite: (email) => alert(`Invited: ${email}`),
|
|
119
|
+
pending: samplePending,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// In context - Modal content
|
|
124
|
+
export const ModalContext: Story = {
|
|
125
|
+
render: () => (
|
|
126
|
+
<div className="bg-paper-50 p-6 rounded-xl w-[450px]">
|
|
127
|
+
<Stack gap="lg">
|
|
128
|
+
<div>
|
|
129
|
+
<Text size="xl" weight="bold">Share "Q4 Budget"</Text>
|
|
130
|
+
<Text size="sm" className="text-ink-500 mt-1">
|
|
131
|
+
Invite team members to collaborate on this document.
|
|
132
|
+
</Text>
|
|
133
|
+
</div>
|
|
134
|
+
<InviteCard
|
|
135
|
+
onInvite={(email) => alert(`Invited: ${email}`)}
|
|
136
|
+
pending={samplePending.slice(0, 2)}
|
|
137
|
+
onCancelInvite={(email) => alert(`Cancelled: ${email}`)}
|
|
138
|
+
/>
|
|
139
|
+
</Stack>
|
|
140
|
+
</div>
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Empty state
|
|
145
|
+
export const EmptyState: Story = {
|
|
146
|
+
render: () => (
|
|
147
|
+
<div className="w-96">
|
|
148
|
+
<InviteCard
|
|
149
|
+
onInvite={(email) => alert(`Invited: ${email}`)}
|
|
150
|
+
pending={[]}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Validation demo
|
|
157
|
+
export const ValidationDemo: Story = {
|
|
158
|
+
render: () => (
|
|
159
|
+
<Stack gap="md" className="w-96">
|
|
160
|
+
<Text size="sm" className="text-ink-500">
|
|
161
|
+
Try submitting with:
|
|
162
|
+
</Text>
|
|
163
|
+
<ul className="text-sm text-ink-500 list-disc list-inside">
|
|
164
|
+
<li>Empty email</li>
|
|
165
|
+
<li>Invalid email format</li>
|
|
166
|
+
<li>Already invited email (alice@example.com)</li>
|
|
167
|
+
</ul>
|
|
168
|
+
<InviteCard
|
|
169
|
+
onInvite={(email) => alert(`Would invite: ${email}`)}
|
|
170
|
+
pending={[{ email: 'alice@example.com', sentAt: new Date() }]}
|
|
171
|
+
/>
|
|
172
|
+
</Stack>
|
|
173
|
+
),
|
|
174
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Send, Clock, X, Mail } from 'lucide-react';
|
|
3
|
+
import Button from './Button';
|
|
4
|
+
import Input from './Input';
|
|
5
|
+
import Stack from './Stack';
|
|
6
|
+
import Text from './Text';
|
|
7
|
+
|
|
8
|
+
export interface PendingInvite {
|
|
9
|
+
/** Email address of the invitee */
|
|
10
|
+
email: string;
|
|
11
|
+
/** When the invitation was sent */
|
|
12
|
+
sentAt: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface InviteCardProps {
|
|
16
|
+
/** Callback when an invitation is sent */
|
|
17
|
+
onInvite: (email: string) => void;
|
|
18
|
+
/** List of pending invitations */
|
|
19
|
+
pending?: PendingInvite[];
|
|
20
|
+
/** Whether an invitation is being sent */
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
/** Maximum pending invites to display */
|
|
23
|
+
maxPending?: number;
|
|
24
|
+
/** Callback to cancel a pending invite */
|
|
25
|
+
onCancelInvite?: (email: string) => void;
|
|
26
|
+
/** Additional CSS classes */
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Format relative time
|
|
31
|
+
function formatRelativeTime(date: Date): string {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const diffMs = now.getTime() - date.getTime();
|
|
34
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
35
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
36
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
37
|
+
|
|
38
|
+
if (diffMins < 1) return 'Just now';
|
|
39
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
40
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
41
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
42
|
+
|
|
43
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Simple email validation
|
|
47
|
+
function isValidEmail(email: string): boolean {
|
|
48
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* InviteCard - Card for inviting collaborators via email.
|
|
53
|
+
*
|
|
54
|
+
* Features:
|
|
55
|
+
* - Email input with validation
|
|
56
|
+
* - Send button with loading state
|
|
57
|
+
* - List of pending invitations
|
|
58
|
+
* - Cancel pending invites
|
|
59
|
+
* - Relative timestamps for pending
|
|
60
|
+
*/
|
|
61
|
+
export function InviteCard({
|
|
62
|
+
onInvite,
|
|
63
|
+
pending = [],
|
|
64
|
+
loading = false,
|
|
65
|
+
maxPending = 5,
|
|
66
|
+
onCancelInvite,
|
|
67
|
+
className = '',
|
|
68
|
+
}: InviteCardProps) {
|
|
69
|
+
const [email, setEmail] = useState('');
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
|
|
72
|
+
const displayedPending = pending.slice(0, maxPending);
|
|
73
|
+
const hasMorePending = pending.length > maxPending;
|
|
74
|
+
|
|
75
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
|
|
78
|
+
const trimmedEmail = email.trim();
|
|
79
|
+
|
|
80
|
+
if (!trimmedEmail) {
|
|
81
|
+
setError('Email is required');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!isValidEmail(trimmedEmail)) {
|
|
86
|
+
setError('Please enter a valid email address');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if already invited
|
|
91
|
+
if (pending.some((p) => p.email.toLowerCase() === trimmedEmail.toLowerCase())) {
|
|
92
|
+
setError('This email has already been invited');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setError(null);
|
|
97
|
+
onInvite(trimmedEmail);
|
|
98
|
+
setEmail('');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleEmailChange = (value: string) => {
|
|
102
|
+
setEmail(value);
|
|
103
|
+
if (error) {
|
|
104
|
+
setError(null);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className={`
|
|
111
|
+
bg-white
|
|
112
|
+
rounded-xl
|
|
113
|
+
border border-paper-200
|
|
114
|
+
shadow-card
|
|
115
|
+
p-4
|
|
116
|
+
${className}
|
|
117
|
+
`}
|
|
118
|
+
>
|
|
119
|
+
<Stack gap="md">
|
|
120
|
+
{/* Header */}
|
|
121
|
+
<Stack direction="horizontal" gap="sm" align="center">
|
|
122
|
+
<div className="p-2 bg-accent-100 rounded-lg">
|
|
123
|
+
<Mail className="w-5 h-5 text-accent-600" />
|
|
124
|
+
</div>
|
|
125
|
+
<div>
|
|
126
|
+
<Text weight="semibold" className="text-ink-800">Invite People</Text>
|
|
127
|
+
<Text size="sm" className="text-ink-500">Share access via email</Text>
|
|
128
|
+
</div>
|
|
129
|
+
</Stack>
|
|
130
|
+
|
|
131
|
+
{/* Invite form */}
|
|
132
|
+
<form onSubmit={handleSubmit}>
|
|
133
|
+
<Stack direction="horizontal" gap="sm">
|
|
134
|
+
<div className="flex-1">
|
|
135
|
+
<Input
|
|
136
|
+
type="email"
|
|
137
|
+
placeholder="Enter email address"
|
|
138
|
+
value={email}
|
|
139
|
+
onChange={(e) => handleEmailChange(e.target.value)}
|
|
140
|
+
validationState={error ? 'error' : undefined}
|
|
141
|
+
validationMessage={error || undefined}
|
|
142
|
+
disabled={loading}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
<Button
|
|
146
|
+
type="submit"
|
|
147
|
+
variant="primary"
|
|
148
|
+
loading={loading}
|
|
149
|
+
disabled={!email.trim()}
|
|
150
|
+
icon={<Send className="w-4 h-4" />}
|
|
151
|
+
>
|
|
152
|
+
Send
|
|
153
|
+
</Button>
|
|
154
|
+
</Stack>
|
|
155
|
+
</form>
|
|
156
|
+
|
|
157
|
+
{/* Pending invites */}
|
|
158
|
+
{displayedPending.length > 0 && (
|
|
159
|
+
<Stack gap="sm">
|
|
160
|
+
<Stack direction="horizontal" gap="xs" align="center">
|
|
161
|
+
<Clock className="w-4 h-4 text-ink-400" />
|
|
162
|
+
<Text size="sm" className="text-ink-500">
|
|
163
|
+
Pending invitations ({pending.length})
|
|
164
|
+
</Text>
|
|
165
|
+
</Stack>
|
|
166
|
+
|
|
167
|
+
<div className="border border-paper-200 rounded-lg divide-y divide-paper-200">
|
|
168
|
+
{displayedPending.map((invite) => (
|
|
169
|
+
<div
|
|
170
|
+
key={invite.email}
|
|
171
|
+
className="px-3 py-2 flex items-center justify-between"
|
|
172
|
+
>
|
|
173
|
+
<Stack gap="xs">
|
|
174
|
+
<Text size="sm" weight="medium" className="text-ink-700">
|
|
175
|
+
{invite.email}
|
|
176
|
+
</Text>
|
|
177
|
+
<Text size="xs" className="text-ink-400">
|
|
178
|
+
Sent {formatRelativeTime(invite.sentAt)}
|
|
179
|
+
</Text>
|
|
180
|
+
</Stack>
|
|
181
|
+
|
|
182
|
+
{onCancelInvite && (
|
|
183
|
+
<button
|
|
184
|
+
onClick={() => onCancelInvite(invite.email)}
|
|
185
|
+
className="p-1 rounded hover:bg-paper-100 text-ink-400 hover:text-error-500 transition-colors"
|
|
186
|
+
aria-label={`Cancel invitation to ${invite.email}`}
|
|
187
|
+
>
|
|
188
|
+
<X className="w-4 h-4" />
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
))}
|
|
193
|
+
|
|
194
|
+
{hasMorePending && (
|
|
195
|
+
<div className="px-3 py-2 text-center">
|
|
196
|
+
<Text size="sm" className="text-ink-400">
|
|
197
|
+
+{pending.length - maxPending} more
|
|
198
|
+
</Text>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
</Stack>
|
|
203
|
+
)}
|
|
204
|
+
</Stack>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default InviteCard;
|