feat: add LinkedIn integration via Unipile with account settings and onboarding
This commit is contained in:
parent
ef7432a338
commit
806edc825a
15 changed files with 520 additions and 8 deletions
|
|
@ -2,3 +2,12 @@
|
||||||
# https://app.supabase.com/project/_/settings/api
|
# https://app.supabase.com/project/_/settings/api
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
|
# Your public app URL (used for Unipile webhook + redirect URLs)
|
||||||
|
NEXT_PUBLIC_APP_URL=https://yourapp.com
|
||||||
|
|
||||||
|
# Unipile LinkedIn Integration
|
||||||
|
# Get these from https://app.unipile.com
|
||||||
|
UNIPILE_API_KEY=your-unipile-api-key
|
||||||
|
UNIPILE_DSN=api4.unipile.com:13441
|
||||||
46
app/api/linkedin/callback/route.ts
Normal file
46
app/api/linkedin/callback/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// This endpoint is called by Unipile (unauthenticated), so we use the service-role client.
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unipile sends: { account_id, name, status, ... }
|
||||||
|
const { account_id, name: userId, status } = body as {
|
||||||
|
account_id?: string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!account_id || !userId) {
|
||||||
|
console.error("Unipile callback missing account_id or name:", body);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing account_id or user identifier" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
unipile_account_id: account_id,
|
||||||
|
unipile_account_status: status ?? "CONNECTED",
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error saving Unipile account_id:", error);
|
||||||
|
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
67
app/api/linkedin/connect/route.ts
Normal file
67
app/api/linkedin/connect/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !userData.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.UNIPILE_API_KEY;
|
||||||
|
const dsn = process.env.UNIPILE_DSN;
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||||
|
|
||||||
|
if (!apiKey || !dsn || !appUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unipile is not configured. Please add UNIPILE_API_KEY, UNIPILE_DSN, and NEXT_PUBLIC_APP_URL to your environment." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has an account (reconnect flow)
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("unipile_account_id")
|
||||||
|
.eq("id", userData.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const type = profile?.unipile_account_id ? "reconnect" : "create";
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
type,
|
||||||
|
providers: ["LINKEDIN"],
|
||||||
|
notify_url: `${appUrl}/api/linkedin/callback`,
|
||||||
|
success_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=connected`,
|
||||||
|
failure_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=failed`,
|
||||||
|
// 'name' is returned as-is in the webhook payload, used to map account_id → user
|
||||||
|
name: userData.user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For reconnect we need the existing account_id
|
||||||
|
if (type === "reconnect" && profile?.unipile_account_id) {
|
||||||
|
body.account_id = profile.unipile_account_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://${dsn}/api/v1/hosted/accounts/link`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-KEY": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error("Unipile connect error:", text);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate LinkedIn connection link" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json({ url: data.url });
|
||||||
|
}
|
||||||
58
app/api/linkedin/disconnect/route.ts
Normal file
58
app/api/linkedin/disconnect/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !userData.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("unipile_account_id")
|
||||||
|
.eq("id", userData.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!profile?.unipile_account_id) {
|
||||||
|
return NextResponse.json({ error: "No LinkedIn account connected" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.UNIPILE_API_KEY;
|
||||||
|
const dsn = process.env.UNIPILE_DSN;
|
||||||
|
|
||||||
|
// Try to remove from Unipile (best-effort; don't block DB cleanup if it fails)
|
||||||
|
if (apiKey && dsn) {
|
||||||
|
const unipileRes = await fetch(
|
||||||
|
`https://${dsn}/api/v1/accounts/${profile.unipile_account_id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "X-API-KEY": apiKey },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!unipileRes.ok) {
|
||||||
|
console.warn(
|
||||||
|
"Unipile delete returned non-OK:",
|
||||||
|
unipileRes.status,
|
||||||
|
await unipileRes.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clear from our DB
|
||||||
|
const { error: dbError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
unipile_account_id: null,
|
||||||
|
unipile_account_status: null,
|
||||||
|
})
|
||||||
|
.eq("id", userData.user.id);
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
console.error("Error clearing Unipile account from profile:", dbError);
|
||||||
|
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
120
app/dashboard/settings/account/page.tsx
Normal file
120
app/dashboard/settings/account/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { CheckCircle2, XCircle, Linkedin } from "lucide-react";
|
||||||
|
import { ConnectLinkedInButton } from "@/components/connect-linkedin-button";
|
||||||
|
import { DisconnectLinkedInButton } from "@/components/disconnect-linkedin-button";
|
||||||
|
|
||||||
|
async function AccountSettingsContent() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
const userId = userData?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("unipile_account_id, unipile_account_status")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const isConnected = !!profile?.unipile_account_id;
|
||||||
|
const accountId = profile?.unipile_account_id as string | null;
|
||||||
|
const status = profile?.unipile_account_status as string | null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold mb-1 text-foreground">Account Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your personal account and integrations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LinkedIn Integration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2 flex items-center gap-2">
|
||||||
|
<Linkedin className="h-5 w-5 text-[#0077B5]" />
|
||||||
|
LinkedIn Integration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-card border border-border rounded-lg p-6 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
LinkedIn Account
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Connect your LinkedIn profile to enable automated outreach campaigns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConnected ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2.5 py-1 rounded-full shrink-0">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground bg-muted px-2.5 py-1 rounded-full shrink-0">
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
Not connected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConnected && (
|
||||||
|
<div className="bg-muted/50 rounded-md p-3 text-xs font-mono text-muted-foreground break-all">
|
||||||
|
<span className="text-foreground/60 mr-2">Account ID:</span>
|
||||||
|
{accountId}
|
||||||
|
{status && status !== "CONNECTED" && (
|
||||||
|
<span className="ml-2 text-amber-500">({status})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
{isConnected ? (
|
||||||
|
<DisconnectLinkedInButton />
|
||||||
|
) : (
|
||||||
|
<ConnectLinkedInButton />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountSettingsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-48 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b border-border pb-2">
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg p-6 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-3 w-64" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-24 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountSettingsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<AccountSettingsSkeleton />}>
|
||||||
|
<AccountSettingsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,6 @@ export async function createOrganization(formData: FormData) {
|
||||||
throw new Error("Failed to assign user to organization.");
|
throw new Error("Failed to assign user to organization.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Redirect to LinkedIn connect step
|
||||||
redirect("/dashboard");
|
redirect("/onboarding/linkedin");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/onboarding/linkedin/page.tsx
Normal file
34
app/onboarding/linkedin/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { OnboardingLinkedInStep } from "@/components/onboarding-linkedin-step";
|
||||||
|
|
||||||
|
async function LinkedInOnboardingGuard() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!data?.user) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have completed org step first
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("organization_id")
|
||||||
|
.eq("id", data.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!profile?.organization_id) {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OnboardingLinkedInStep />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinkedInOnboardingPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex h-screen items-center justify-center">Loading…</div>}>
|
||||||
|
<LinkedInOnboardingGuard />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,8 +45,8 @@ async function OnboardingContent() {
|
||||||
if (invite && new Date(invite.expires_at) > new Date()) {
|
if (invite && new Date(invite.expires_at) > new Date()) {
|
||||||
await supabase.from("profiles").update({ organization_id: invite.organization_id, role: "member" }).eq("id", data.user.id);
|
await supabase.from("profiles").update({ organization_id: invite.organization_id, role: "member" }).eq("id", data.user.id);
|
||||||
await supabaseAdmin.from("invitations").update({ status: "accepted" }).eq("id", invite.id);
|
await supabaseAdmin.from("invitations").update({ status: "accepted" }).eq("id", invite.id);
|
||||||
// Optional: Clear the cookie, but next redirect will naturally ignore it since organization_id is set
|
// Send invitees through the LinkedIn connect step as well
|
||||||
redirect("/dashboard");
|
redirect("/onboarding/linkedin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
45
components/connect-linkedin-button.tsx
Normal file
45
components/connect-linkedin-button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Linkedin, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function ConnectLinkedInButton() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/linkedin/connect", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Something went wrong");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch {
|
||||||
|
setError("Failed to initiate connection. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 bg-[#0077B5] hover:bg-[#005983] text-white px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Linkedin className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{loading ? "Redirecting…" : "Connect LinkedIn"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/disconnect-linkedin-button.tsx
Normal file
48
components/disconnect-linkedin-button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Loader2, Unlink } from "lucide-react";
|
||||||
|
|
||||||
|
export function DisconnectLinkedInButton() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
if (!confirm("Are you sure you want to disconnect your LinkedIn account?")) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/linkedin/disconnect", { method: "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Something went wrong");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to disconnect. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 border border-destructive text-destructive hover:bg-destructive/10 px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Unlink className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{loading ? "Disconnecting…" : "Disconnect"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ export function LoginForm({
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||||
router.push("/protected");
|
router.push("/dashboard");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
80
components/onboarding-linkedin-step.tsx
Normal file
80
components/onboarding-linkedin-step.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Linkedin, Loader2, ArrowRight } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function OnboardingLinkedInStep() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/linkedin/connect", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Something went wrong. You can connect later in Settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch {
|
||||||
|
setError("Could not connect to LinkedIn. You can try later from Settings.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="w-full max-w-md p-8 bg-card rounded-lg border border-border shadow-sm">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-[#0077B5]/10 rounded-full p-3">
|
||||||
|
<Linkedin className="h-8 w-8 text-[#0077B5]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold mb-2">Connect LinkedIn</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Connect your LinkedIn account to start automated outreach campaigns. You
|
||||||
|
can also do this later from your Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 bg-[#0077B5] hover:bg-[#005983] text-white py-2.5 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Linkedin className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{loading ? "Redirecting to LinkedIn…" : "Connect LinkedIn Account"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground text-sm transition-colors py-2"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-4 text-xs text-destructive text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { FC, useState, useEffect } from "react";
|
import { FC, useState, useEffect } from "react";
|
||||||
import { LayoutDashboard, Users, ChevronLeft, ChevronRight, User, LogOut } from "lucide-react";
|
import { LayoutDashboard, Users, ChevronLeft, ChevronRight, User, LogOut, Settings } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import {
|
import {
|
||||||
|
|
@ -130,6 +130,11 @@ export const Sidebar: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/dashboard/settings/account")}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive focus:bg-destructive/10" onClick={handleLogout}>
|
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive focus:bg-destructive/10" onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>Log out</span>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function SignUpForm({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: `${window.location.origin}/protected`,
|
emailRedirectTo: `${window.location.origin}/dashboard`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function UpdatePasswordForm({
|
||||||
const { error } = await supabase.auth.updateUser({ password });
|
const { error } = await supabase.auth.updateUser({ password });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||||
router.push("/protected");
|
router.push("/dashboard");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue