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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.13.3",
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
- const result = await validateSupabaseConnection(url, anonKey);
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 anonymous (anon) API key</li>
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">Supabase URL</Label>
134
- <Input
135
- id="supabase-url"
136
- placeholder="https://xxxxx.supabase.co"
137
- value={url}
138
- onChange={(e) => setUrl(e.target.value)}
139
- />
140
- <p className="text-xs text-muted-foreground">
141
- Found in Project Settings → API
142
- </p>
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">Anonymous Key</Label>
147
- <Input
148
- id="anon-key"
149
- type="password"
150
- placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
151
- value={anonKey}
152
- onChange={(e) => setAnonKey(e.target.value)}
153
- />
154
- <p className="text-xs text-muted-foreground">
155
- Found in Project Settings → API → Project API keys
156
- </p>
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={!url || !anonKey}
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 anon key validation (JWT format)
82
- if (!anonKey.startsWith('eyJ')) {
83
- return { valid: false, error: 'Invalid anon key format' };
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