realtimex-crm 0.13.3 → 0.13.7
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/assets/{DealList-C_bZazrP.js → DealList-D4GG0g38.js} +2 -2
- package/dist/assets/{DealList-C_bZazrP.js.map → DealList-D4GG0g38.js.map} +1 -1
- package/dist/assets/{index-6eOQryrt.js → index-B1VkO6yf.js} +55 -55
- package/dist/assets/{index-6eOQryrt.js.map → index-B1VkO6yf.js.map} +1 -1
- package/dist/assets/index-u4GyWWrL.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +1 -1
- package/src/components/atomic-crm/contacts/ContactAside.tsx +3 -0
- package/src/components/atomic-crm/contacts/ContactHealthCard.tsx +201 -0
- package/src/components/atomic-crm/contacts/ContactListFilter.tsx +47 -1
- package/src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts +37 -0
- package/src/components/atomic-crm/setup/SupabaseSetupWizard.tsx +174 -28
- package/src/components/atomic-crm/types.ts +20 -0
- package/src/lib/supabase-config.ts +12 -3
- package/supabase/migrations/20251226100000_add_contact_heartbeats.sql +237 -0
- package/supabase/migrations/20251226101500_backfill_contact_heartbeats.sql +12 -0
- package/dist/assets/index-BFJ29Og3.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.7",
|
|
4
4
|
"description": "RealTimeX CRM - A full-featured CRM built with React, shadcn-admin-kit, and Supabase. Fork of Atomic CRM with RealTimeX App SDK integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,6 +21,7 @@ import { SaleName } from "../sales/SaleName";
|
|
|
21
21
|
import type { Contact } from "../types";
|
|
22
22
|
import { ContactMergeButton } from "./ContactMergeButton";
|
|
23
23
|
import { ExportVCardButton } from "./ExportVCardButton";
|
|
24
|
+
import { ContactHealthCard } from "./ContactHealthCard";
|
|
24
25
|
|
|
25
26
|
export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
|
|
26
27
|
const { contactGender } = useConfigurationContext();
|
|
@@ -37,6 +38,8 @@ export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
|
|
|
37
38
|
)}
|
|
38
39
|
</div>
|
|
39
40
|
|
|
41
|
+
<ContactHealthCard />
|
|
42
|
+
|
|
40
43
|
<AsideSection title="Personal info">
|
|
41
44
|
<ArrayField source="email_jsonb">
|
|
42
45
|
<SingleFieldList className="flex-col">
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { formatDistance } from "date-fns";
|
|
2
|
+
import { Activity, HeartPulse, Mail, Linkedin } from "lucide-react";
|
|
3
|
+
import { useRecordContext } from "ra-core";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Progress } from "@/components/ui/progress";
|
|
6
|
+
|
|
7
|
+
import { AsideSection } from "../misc/AsideSection";
|
|
8
|
+
import type { Contact } from "../types";
|
|
9
|
+
|
|
10
|
+
const InternalStatusBadge = ({ status }: { status: string }) => {
|
|
11
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
12
|
+
let className = "";
|
|
13
|
+
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "strong":
|
|
16
|
+
variant = "default";
|
|
17
|
+
break;
|
|
18
|
+
case "active":
|
|
19
|
+
variant = "secondary";
|
|
20
|
+
className = "bg-green-100 text-green-800 hover:bg-green-100";
|
|
21
|
+
break;
|
|
22
|
+
case "cooling":
|
|
23
|
+
variant = "secondary";
|
|
24
|
+
className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
|
|
25
|
+
break;
|
|
26
|
+
case "cold":
|
|
27
|
+
variant = "secondary";
|
|
28
|
+
className = "bg-orange-100 text-orange-800 hover:bg-orange-100";
|
|
29
|
+
break;
|
|
30
|
+
case "dormant":
|
|
31
|
+
variant = "secondary";
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Badge variant={variant} className={className}>
|
|
37
|
+
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const EmailStatusBadge = ({ status }: { status: string }) => {
|
|
43
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
44
|
+
let className = "";
|
|
45
|
+
|
|
46
|
+
switch (status) {
|
|
47
|
+
case "valid":
|
|
48
|
+
variant = "secondary";
|
|
49
|
+
className = "bg-green-100 text-green-800 hover:bg-green-100";
|
|
50
|
+
break;
|
|
51
|
+
case "risky":
|
|
52
|
+
variant = "secondary";
|
|
53
|
+
className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
|
|
54
|
+
break;
|
|
55
|
+
case "invalid":
|
|
56
|
+
variant = "destructive";
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Badge variant={variant} className={className}>
|
|
62
|
+
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
63
|
+
</Badge>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const LinkedInStatusBadge = ({ status }: { status: string }) => {
|
|
68
|
+
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
|
69
|
+
let className = "";
|
|
70
|
+
|
|
71
|
+
switch (status) {
|
|
72
|
+
case "active":
|
|
73
|
+
variant = "default";
|
|
74
|
+
className = "bg-[#0077b5] hover:bg-[#0077b5]"; // LinkedIn Blue
|
|
75
|
+
break;
|
|
76
|
+
case "inactive":
|
|
77
|
+
variant = "secondary";
|
|
78
|
+
break;
|
|
79
|
+
case "not_found":
|
|
80
|
+
variant = "destructive";
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Badge variant={variant} className={className}>
|
|
86
|
+
{status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
|
|
87
|
+
</Badge>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ContactHealthCard = () => {
|
|
92
|
+
const record = useRecordContext<Contact>();
|
|
93
|
+
if (!record) return null;
|
|
94
|
+
|
|
95
|
+
const daysSince = record.days_since_last_activity ??
|
|
96
|
+
(record.last_seen ? Math.floor((new Date().getTime() - new Date(record.last_seen).getTime()) / (1000 * 60 * 60 * 24)) : undefined);
|
|
97
|
+
|
|
98
|
+
const hasInternalHealth =
|
|
99
|
+
record.internal_heartbeat_score != null ||
|
|
100
|
+
record.internal_heartbeat_status != null ||
|
|
101
|
+
daysSince != null;
|
|
102
|
+
|
|
103
|
+
const hasExternalHealth =
|
|
104
|
+
record.external_heartbeat_status != null ||
|
|
105
|
+
record.email_validation_status != null ||
|
|
106
|
+
record.linkedin_profile_status != null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<AsideSection title="Contact Health">
|
|
110
|
+
{!hasInternalHealth && !hasExternalHealth && (
|
|
111
|
+
<div className="text-xs text-muted-foreground italic">
|
|
112
|
+
No health data calculated yet.
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Internal Heartbeat (Relationship Strength) */}
|
|
117
|
+
{hasInternalHealth && (
|
|
118
|
+
<div className="mb-4">
|
|
119
|
+
<div className="flex items-center gap-2 mb-2">
|
|
120
|
+
<Activity className="w-4 h-4 text-muted-foreground" />
|
|
121
|
+
<span className="font-medium text-sm">Relationship Strength</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{record.internal_heartbeat_score != null && (
|
|
125
|
+
<div className="mb-2">
|
|
126
|
+
<div className="flex items-center justify-between mb-1">
|
|
127
|
+
<span className="text-xs text-muted-foreground">
|
|
128
|
+
Engagement Score
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-xs font-medium">
|
|
131
|
+
{record.internal_heartbeat_score}/100
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
<Progress
|
|
135
|
+
value={record.internal_heartbeat_score}
|
|
136
|
+
className="h-2"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{record.internal_heartbeat_status && (
|
|
142
|
+
<div className="mb-2">
|
|
143
|
+
<InternalStatusBadge status={record.internal_heartbeat_status} />
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{daysSince != null && (
|
|
148
|
+
<div className="text-xs text-muted-foreground">
|
|
149
|
+
Last activity:{" "}
|
|
150
|
+
{daysSince === 0
|
|
151
|
+
? "Today"
|
|
152
|
+
: daysSince === 1
|
|
153
|
+
? "Yesterday"
|
|
154
|
+
: `${daysSince} days ago`}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* External Heartbeat (Contact Validation) */}
|
|
161
|
+
{hasExternalHealth && (
|
|
162
|
+
<div>
|
|
163
|
+
<div className="flex items-center gap-2 mb-2">
|
|
164
|
+
<HeartPulse className="w-4 h-4 text-muted-foreground" />
|
|
165
|
+
<span className="font-medium text-sm">Contact Validation</span>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{record.email_validation_status && (
|
|
169
|
+
<div className="flex items-center gap-2 mb-2">
|
|
170
|
+
<Mail className="w-3 h-3 text-muted-foreground" />
|
|
171
|
+
<EmailStatusBadge status={record.email_validation_status} />
|
|
172
|
+
{record.email_last_bounced_at && (
|
|
173
|
+
<span className="text-xs text-destructive">
|
|
174
|
+
(bounced)
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{record.linkedin_profile_status && (
|
|
181
|
+
<div className="flex items-center gap-2 mb-2">
|
|
182
|
+
<Linkedin className="w-3 h-3 text-muted-foreground" />
|
|
183
|
+
<LinkedInStatusBadge status={record.linkedin_profile_status} />
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{record.external_heartbeat_checked_at && (
|
|
188
|
+
<div className="text-xs text-muted-foreground">
|
|
189
|
+
Validated{" "}
|
|
190
|
+
{formatDistance(
|
|
191
|
+
new Date(record.external_heartbeat_checked_at),
|
|
192
|
+
new Date(),
|
|
193
|
+
{ addSuffix: true },
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</AsideSection>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { endOfYesterday, startOfMonth, startOfWeek, subMonths } from "date-fns";
|
|
2
|
-
import { CheckSquare, Clock, Tag, TrendingUp, Users } from "lucide-react";
|
|
2
|
+
import { Activity, CheckSquare, Clock, HeartPulse, Tag, TrendingUp, Users } from "lucide-react";
|
|
3
3
|
import { FilterLiveForm, useGetIdentity, useGetList } from "ra-core";
|
|
4
4
|
import { ToggleFilterButton } from "@/components/admin/toggle-filter-button";
|
|
5
5
|
import { SearchInput } from "@/components/admin/search-input";
|
|
@@ -69,6 +69,52 @@ export const ContactListFilter = () => {
|
|
|
69
69
|
/>
|
|
70
70
|
</FilterCategory>
|
|
71
71
|
|
|
72
|
+
<FilterCategory label="Engagement" icon={<Activity />}>
|
|
73
|
+
<ToggleFilterButton
|
|
74
|
+
className="w-full justify-between"
|
|
75
|
+
label="Strong"
|
|
76
|
+
value={{ internal_heartbeat_status: "strong" }}
|
|
77
|
+
/>
|
|
78
|
+
<ToggleFilterButton
|
|
79
|
+
className="w-full justify-between"
|
|
80
|
+
label="Active"
|
|
81
|
+
value={{ internal_heartbeat_status: "active" }}
|
|
82
|
+
/>
|
|
83
|
+
<ToggleFilterButton
|
|
84
|
+
className="w-full justify-between"
|
|
85
|
+
label="Cooling"
|
|
86
|
+
value={{ internal_heartbeat_status: "cooling" }}
|
|
87
|
+
/>
|
|
88
|
+
<ToggleFilterButton
|
|
89
|
+
className="w-full justify-between"
|
|
90
|
+
label="Cold"
|
|
91
|
+
value={{ internal_heartbeat_status: "cold" }}
|
|
92
|
+
/>
|
|
93
|
+
<ToggleFilterButton
|
|
94
|
+
className="w-full justify-between"
|
|
95
|
+
label="Dormant"
|
|
96
|
+
value={{ internal_heartbeat_status: "dormant" }}
|
|
97
|
+
/>
|
|
98
|
+
</FilterCategory>
|
|
99
|
+
|
|
100
|
+
<FilterCategory label="Validation" icon={<HeartPulse />}>
|
|
101
|
+
<ToggleFilterButton
|
|
102
|
+
className="w-full justify-between"
|
|
103
|
+
label="Valid Email"
|
|
104
|
+
value={{ email_validation_status: "valid" }}
|
|
105
|
+
/>
|
|
106
|
+
<ToggleFilterButton
|
|
107
|
+
className="w-full justify-between"
|
|
108
|
+
label="Risky Email"
|
|
109
|
+
value={{ email_validation_status: "risky" }}
|
|
110
|
+
/>
|
|
111
|
+
<ToggleFilterButton
|
|
112
|
+
className="w-full justify-between"
|
|
113
|
+
label="Invalid Email"
|
|
114
|
+
value={{ email_validation_status: "invalid" }}
|
|
115
|
+
/>
|
|
116
|
+
</FilterCategory>
|
|
117
|
+
|
|
72
118
|
<FilterCategory label="Status" icon={<TrendingUp />}>
|
|
73
119
|
{noteStatuses.map((status) => (
|
|
74
120
|
<ToggleFilterButton
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
name,
|
|
6
6
|
phone,
|
|
7
7
|
random,
|
|
8
|
+
date,
|
|
9
|
+
datatype,
|
|
8
10
|
} from "faker/locale/en_US";
|
|
9
11
|
|
|
10
12
|
import {
|
|
@@ -75,6 +77,38 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
|
|
|
75
77
|
const first_seen = randomDate(new Date(company.created_at)).toISOString();
|
|
76
78
|
const last_seen = first_seen;
|
|
77
79
|
|
|
80
|
+
// Heartbeat generation
|
|
81
|
+
const daysInactive = datatype.number({ min: 0, max: 365 });
|
|
82
|
+
|
|
83
|
+
const computeScore = (days: number): number => {
|
|
84
|
+
if (days <= 7) return datatype.number({ min: 80, max: 100 });
|
|
85
|
+
if (days <= 30) return datatype.number({ min: 60, max: 79 });
|
|
86
|
+
if (days <= 90) return datatype.number({ min: 40, max: 59 });
|
|
87
|
+
if (days <= 180) return datatype.number({ min: 20, max: 39 });
|
|
88
|
+
return datatype.number({ min: 0, max: 19 });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const score = computeScore(daysInactive);
|
|
92
|
+
|
|
93
|
+
// Internal heartbeat (70% populated)
|
|
94
|
+
const internalHeartbeat = Math.random() > 0.3 ? {
|
|
95
|
+
internal_heartbeat_score: score,
|
|
96
|
+
internal_heartbeat_status:
|
|
97
|
+
score >= 80 ? 'strong' :
|
|
98
|
+
score >= 60 ? 'active' :
|
|
99
|
+
score >= 40 ? 'cooling' :
|
|
100
|
+
score >= 20 ? 'cold' : 'dormant',
|
|
101
|
+
internal_heartbeat_updated_at: date.recent(7).toISOString(),
|
|
102
|
+
} : {};
|
|
103
|
+
|
|
104
|
+
// External heartbeat (50% populated)
|
|
105
|
+
const externalHeartbeat = Math.random() > 0.5 ? {
|
|
106
|
+
external_heartbeat_status: random.arrayElement(['valid', 'warning', 'invalid', 'unknown']),
|
|
107
|
+
external_heartbeat_checked_at: date.recent(30).toISOString(),
|
|
108
|
+
email_validation_status: random.arrayElement(['valid', 'risky', 'invalid', 'unknown']),
|
|
109
|
+
linkedin_profile_status: random.arrayElement(['active', 'inactive', 'not_found', 'unknown']),
|
|
110
|
+
} : {};
|
|
111
|
+
|
|
78
112
|
return {
|
|
79
113
|
id,
|
|
80
114
|
first_name,
|
|
@@ -98,6 +132,9 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
|
|
|
98
132
|
sales_id: company.sales_id,
|
|
99
133
|
nb_tasks: 0,
|
|
100
134
|
linkedin_url: null,
|
|
135
|
+
...internalHeartbeat,
|
|
136
|
+
...externalHeartbeat,
|
|
137
|
+
days_since_last_activity: daysInactive,
|
|
101
138
|
};
|
|
102
139
|
});
|
|
103
140
|
};
|
|
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
|
|
10
10
|
import { Input } from "@/components/ui/input";
|
|
11
11
|
import { Label } from "@/components/ui/label";
|
|
12
12
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
13
|
-
import { Loader2, Database, CheckCircle, AlertCircle } from "lucide-react";
|
|
13
|
+
import { Loader2, Database, CheckCircle, AlertCircle, ExternalLink, Check } from "lucide-react";
|
|
14
14
|
import {
|
|
15
15
|
saveSupabaseConfig,
|
|
16
16
|
validateSupabaseConnection,
|
|
@@ -24,6 +24,77 @@ interface SupabaseSetupWizardProps {
|
|
|
24
24
|
canClose?: boolean;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes Supabase URL input - accepts either full URL or just project ID
|
|
29
|
+
*/
|
|
30
|
+
function normalizeSupabaseUrl(input: string): string {
|
|
31
|
+
const trimmed = input.trim();
|
|
32
|
+
if (!trimmed) return "";
|
|
33
|
+
|
|
34
|
+
// If it starts with http:// or https://, treat as full URL
|
|
35
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
36
|
+
return trimmed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Otherwise, treat as project ID and construct full URL
|
|
40
|
+
return `https://${trimmed}.supabase.co`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates if input looks like a valid Supabase URL or project ID
|
|
45
|
+
*/
|
|
46
|
+
function validateUrlFormat(input: string): { valid: boolean; message?: string } {
|
|
47
|
+
const trimmed = input.trim();
|
|
48
|
+
if (!trimmed) return { valid: false };
|
|
49
|
+
|
|
50
|
+
// Check if it's a full URL
|
|
51
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(trimmed);
|
|
54
|
+
if (url.hostname.endsWith(".supabase.co")) {
|
|
55
|
+
return { valid: true, message: "Valid Supabase URL" };
|
|
56
|
+
}
|
|
57
|
+
return { valid: false, message: "URL must be a Supabase domain" };
|
|
58
|
+
} catch {
|
|
59
|
+
return { valid: false, message: "Invalid URL format" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if it's a project ID (alphanumeric, typically 20 chars)
|
|
64
|
+
if (/^[a-z0-9]+$/.test(trimmed)) {
|
|
65
|
+
return { valid: true, message: "Valid project ID (will expand to full URL)" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { valid: false, message: "Enter full URL or project ID" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates if input looks like a valid Supabase API key
|
|
73
|
+
*/
|
|
74
|
+
function validateKeyFormat(input: string): { valid: boolean; message?: string } {
|
|
75
|
+
const trimmed = input.trim();
|
|
76
|
+
if (!trimmed) return { valid: false };
|
|
77
|
+
|
|
78
|
+
// New publishable keys start with "sb_publishable_" followed by key content
|
|
79
|
+
if (trimmed.startsWith("sb_publishable_")) {
|
|
80
|
+
// Check that there's actual key content after the prefix (at least 20 chars)
|
|
81
|
+
if (trimmed.length > "sb_publishable_".length + 20) {
|
|
82
|
+
return { valid: true, message: "Valid publishable key format" };
|
|
83
|
+
}
|
|
84
|
+
return { valid: false, message: "Publishable key seems incomplete" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Legacy anon keys are JWT tokens starting with "eyJ"
|
|
88
|
+
if (trimmed.startsWith("eyJ")) {
|
|
89
|
+
if (trimmed.length > 100) {
|
|
90
|
+
return { valid: true, message: "Valid anon key format" };
|
|
91
|
+
}
|
|
92
|
+
return { valid: false, message: "Anon key seems incomplete" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { valid: false, message: "Must be a valid Supabase API key (anon or publishable)" };
|
|
96
|
+
}
|
|
97
|
+
|
|
27
98
|
export function SupabaseSetupWizard({
|
|
28
99
|
open,
|
|
29
100
|
onComplete,
|
|
@@ -33,15 +104,21 @@ export function SupabaseSetupWizard({
|
|
|
33
104
|
const [url, setUrl] = useState("");
|
|
34
105
|
const [anonKey, setAnonKey] = useState("");
|
|
35
106
|
const [error, setError] = useState<string | null>(null);
|
|
107
|
+
const [urlTouched, setUrlTouched] = useState(false);
|
|
108
|
+
const [keyTouched, setKeyTouched] = useState(false);
|
|
36
109
|
|
|
37
110
|
const handleValidateAndSave = async () => {
|
|
38
111
|
setError(null);
|
|
39
112
|
setStep("validating");
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
// Normalize the URL before validation
|
|
115
|
+
const normalizedUrl = normalizeSupabaseUrl(url);
|
|
116
|
+
const trimmedKey = anonKey.trim();
|
|
117
|
+
|
|
118
|
+
const result = await validateSupabaseConnection(normalizedUrl, trimmedKey);
|
|
42
119
|
|
|
43
120
|
if (result.valid) {
|
|
44
|
-
saveSupabaseConfig({ url, anonKey });
|
|
121
|
+
saveSupabaseConfig({ url: normalizedUrl, anonKey: trimmedKey });
|
|
45
122
|
setStep("success");
|
|
46
123
|
|
|
47
124
|
// Reload after short delay to apply new config
|
|
@@ -55,6 +132,12 @@ export function SupabaseSetupWizard({
|
|
|
55
132
|
}
|
|
56
133
|
};
|
|
57
134
|
|
|
135
|
+
// Get validation states
|
|
136
|
+
const urlValidation = url ? validateUrlFormat(url) : { valid: false };
|
|
137
|
+
const keyValidation = anonKey ? validateKeyFormat(anonKey) : { valid: false };
|
|
138
|
+
const normalizedUrl = url ? normalizeSupabaseUrl(url) : "";
|
|
139
|
+
const showUrlExpansion = url && !url.startsWith("http") && urlValidation.valid;
|
|
140
|
+
|
|
58
141
|
const handleClose = () => {
|
|
59
142
|
if (canClose) {
|
|
60
143
|
onComplete();
|
|
@@ -90,9 +173,10 @@ export function SupabaseSetupWizard({
|
|
|
90
173
|
href="https://supabase.com"
|
|
91
174
|
target="_blank"
|
|
92
175
|
rel="noopener noreferrer"
|
|
93
|
-
className="underline text-primary"
|
|
176
|
+
className="underline text-primary inline-flex items-center gap-1"
|
|
94
177
|
>
|
|
95
178
|
supabase.com
|
|
179
|
+
<ExternalLink className="h-3 w-3" />
|
|
96
180
|
</a>
|
|
97
181
|
</AlertDescription>
|
|
98
182
|
</Alert>
|
|
@@ -100,11 +184,23 @@ export function SupabaseSetupWizard({
|
|
|
100
184
|
<div className="space-y-2">
|
|
101
185
|
<h4 className="font-medium text-sm">What you'll need:</h4>
|
|
102
186
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
|
103
|
-
<li>Your Supabase project URL</li>
|
|
104
|
-
<li>Your
|
|
187
|
+
<li>Your Supabase project URL or project ID</li>
|
|
188
|
+
<li>Your API key (anon or publishable key)</li>
|
|
105
189
|
</ul>
|
|
106
190
|
</div>
|
|
107
191
|
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
<a
|
|
194
|
+
href="https://supabase.com/docs/guides/api#api-url-and-keys"
|
|
195
|
+
target="_blank"
|
|
196
|
+
rel="noopener noreferrer"
|
|
197
|
+
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
|
198
|
+
>
|
|
199
|
+
Where do I find these?
|
|
200
|
+
<ExternalLink className="h-3 w-3" />
|
|
201
|
+
</a>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
108
204
|
<Button onClick={() => setStep("credentials")} className="w-full">
|
|
109
205
|
Continue
|
|
110
206
|
</Button>
|
|
@@ -130,30 +226,80 @@ export function SupabaseSetupWizard({
|
|
|
130
226
|
)}
|
|
131
227
|
|
|
132
228
|
<div className="space-y-2">
|
|
133
|
-
<Label htmlFor="supabase-url">
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
229
|
+
<Label htmlFor="supabase-url">Project URL or ID</Label>
|
|
230
|
+
<div className="relative">
|
|
231
|
+
<Input
|
|
232
|
+
id="supabase-url"
|
|
233
|
+
placeholder="xxxxx or https://xxxxx.supabase.co"
|
|
234
|
+
value={url}
|
|
235
|
+
onChange={(e) => {
|
|
236
|
+
setUrl(e.target.value);
|
|
237
|
+
setUrlTouched(true);
|
|
238
|
+
}}
|
|
239
|
+
onBlur={() => setUrlTouched(true)}
|
|
240
|
+
className={
|
|
241
|
+
urlTouched && url
|
|
242
|
+
? urlValidation.valid
|
|
243
|
+
? "pr-8 border-green-500"
|
|
244
|
+
: "pr-8 border-destructive"
|
|
245
|
+
: ""
|
|
246
|
+
}
|
|
247
|
+
/>
|
|
248
|
+
{urlTouched && url && urlValidation.valid && (
|
|
249
|
+
<Check className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
{showUrlExpansion && (
|
|
253
|
+
<div className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
|
|
254
|
+
<Check className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
|
255
|
+
<span>Will expand to: {normalizedUrl}</span>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
{urlTouched && url && urlValidation.message && !urlValidation.valid && (
|
|
259
|
+
<p className="text-xs text-destructive">{urlValidation.message}</p>
|
|
260
|
+
)}
|
|
261
|
+
{(!urlTouched || !url) && (
|
|
262
|
+
<p className="text-xs text-muted-foreground">
|
|
263
|
+
Enter full URL or just the project ID (from Project Settings → API)
|
|
264
|
+
</p>
|
|
265
|
+
)}
|
|
143
266
|
</div>
|
|
144
267
|
|
|
145
268
|
<div className="space-y-2">
|
|
146
|
-
<Label htmlFor="anon-key">
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
269
|
+
<Label htmlFor="anon-key">API Key</Label>
|
|
270
|
+
<div className="relative">
|
|
271
|
+
<Input
|
|
272
|
+
id="anon-key"
|
|
273
|
+
type="password"
|
|
274
|
+
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
275
|
+
value={anonKey}
|
|
276
|
+
onChange={(e) => {
|
|
277
|
+
setAnonKey(e.target.value);
|
|
278
|
+
setKeyTouched(true);
|
|
279
|
+
}}
|
|
280
|
+
onBlur={() => setKeyTouched(true)}
|
|
281
|
+
className={
|
|
282
|
+
keyTouched && anonKey
|
|
283
|
+
? keyValidation.valid
|
|
284
|
+
? "pr-8 border-green-500"
|
|
285
|
+
: "pr-8 border-destructive"
|
|
286
|
+
: ""
|
|
287
|
+
}
|
|
288
|
+
/>
|
|
289
|
+
{keyTouched && anonKey && keyValidation.valid && (
|
|
290
|
+
<Check className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
{keyTouched && anonKey && keyValidation.message && (
|
|
294
|
+
<p className={`text-xs ${keyValidation.valid ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
|
295
|
+
{keyValidation.message}
|
|
296
|
+
</p>
|
|
297
|
+
)}
|
|
298
|
+
{(!keyTouched || !anonKey) && (
|
|
299
|
+
<p className="text-xs text-muted-foreground">
|
|
300
|
+
Anon or publishable key (from Project Settings → API)
|
|
301
|
+
</p>
|
|
302
|
+
)}
|
|
157
303
|
</div>
|
|
158
304
|
|
|
159
305
|
<div className="flex gap-2">
|
|
@@ -166,7 +312,7 @@ export function SupabaseSetupWizard({
|
|
|
166
312
|
</Button>
|
|
167
313
|
<Button
|
|
168
314
|
onClick={handleValidateAndSave}
|
|
169
|
-
disabled={!
|
|
315
|
+
disabled={!urlValidation.valid || !keyValidation.valid}
|
|
170
316
|
className="flex-1"
|
|
171
317
|
>
|
|
172
318
|
Connect
|
|
@@ -142,6 +142,26 @@ export type Contact = {
|
|
|
142
142
|
phone_jsonb: PhoneNumberAndType[];
|
|
143
143
|
nb_tasks?: number;
|
|
144
144
|
company_name?: string;
|
|
145
|
+
|
|
146
|
+
// Internal Heartbeat
|
|
147
|
+
internal_heartbeat_score?: number;
|
|
148
|
+
internal_heartbeat_status?: string; // 'strong' | 'active' | 'cooling' | 'cold' | 'dormant'
|
|
149
|
+
internal_heartbeat_updated_at?: string;
|
|
150
|
+
|
|
151
|
+
// External Heartbeat
|
|
152
|
+
external_heartbeat_status?: string; // 'valid' | 'warning' | 'invalid' | 'unknown'
|
|
153
|
+
external_heartbeat_checked_at?: string;
|
|
154
|
+
email_validation_status?: string; // 'valid' | 'risky' | 'invalid' | 'unknown'
|
|
155
|
+
email_last_bounced_at?: string;
|
|
156
|
+
linkedin_profile_status?: string; // 'active' | 'inactive' | 'not_found' | 'unknown'
|
|
157
|
+
employment_verified_at?: string;
|
|
158
|
+
|
|
159
|
+
// View-computed fields
|
|
160
|
+
nb_completed_tasks?: number;
|
|
161
|
+
task_completion_rate?: number;
|
|
162
|
+
last_note_date?: string;
|
|
163
|
+
last_task_activity?: string;
|
|
164
|
+
days_since_last_activity?: number;
|
|
145
165
|
} & Pick<RaRecord, "id">;
|
|
146
166
|
|
|
147
167
|
export type ContactNote = {
|
|
@@ -78,9 +78,18 @@ export async function validateSupabaseConnection(
|
|
|
78
78
|
return { valid: false, error: 'Invalid URL format' };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Basic
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Basic API key validation (supports both JWT anon keys and publishable keys)
|
|
82
|
+
const isJwtKey = anonKey.startsWith('eyJ');
|
|
83
|
+
const isPublishableKey = anonKey.startsWith('sb_publishable_');
|
|
84
|
+
|
|
85
|
+
if (!isJwtKey && !isPublishableKey) {
|
|
86
|
+
return { valid: false, error: 'Invalid API key format (must be anon or publishable key)' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Skip network validation for publishable keys as they might not be standard JWTs
|
|
90
|
+
// compatible with the Postgrest root endpoint check
|
|
91
|
+
if (isPublishableKey) {
|
|
92
|
+
return { valid: true };
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
// Test connection by making a simple request
|