Build AI outreach flow
Implements full research and email generation workflow: - Added research-company edge function to perform Firecrawl-based company research and Lovable AI email generation, returning personalized email content. - Enhanced LeadsGenerator UI to support per-lead research, show AI-generated emails, and copy email content, with a new dialog for previews. - Wired edge function deployment and config updates, enabling generation of personalized outreach emails for selected leads. X-Lovable-Edit-ID: edt-9738b1ad-729e-441b-8266-2bd5f79bd3b9
This commit is contained in:
@@ -3,7 +3,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Sparkles, RefreshCw, Building2, Users, MapPin } from "lucide-react";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Sparkles, RefreshCw, Building2, Users, MapPin, Mail, Search, Copy, Check } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
@@ -15,9 +16,24 @@ interface Lead {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmailContent {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
personalization_points: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchResult {
|
||||||
|
company_name: string;
|
||||||
|
research_summary: string;
|
||||||
|
email: EmailContent;
|
||||||
|
}
|
||||||
|
|
||||||
export function LeadsGenerator() {
|
export function LeadsGenerator() {
|
||||||
const [leads, setLeads] = useState<Lead[]>([]);
|
const [leads, setLeads] = useState<Lead[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [researchingLead, setResearchingLead] = useState<string | null>(null);
|
||||||
|
const [emailDialog, setEmailDialog] = useState<ResearchResult | null>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const generateLeads = async () => {
|
const generateLeads = async () => {
|
||||||
@@ -57,6 +73,58 @@ export function LeadsGenerator() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const researchCompany = async (lead: Lead) => {
|
||||||
|
setResearchingLead(lead.company_name);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/research-company`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_name: lead.company_name,
|
||||||
|
district: lead.district,
|
||||||
|
industry: lead.industry,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Kunde inte researcha företaget");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ResearchResult = await response.json();
|
||||||
|
setEmailDialog(data);
|
||||||
|
toast({
|
||||||
|
title: "Research klar!",
|
||||||
|
description: `Personligt mejl genererat för ${lead.company_name}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error researching company:", error);
|
||||||
|
toast({
|
||||||
|
title: "Fel",
|
||||||
|
description: error instanceof Error ? error.message : "Kunde inte researcha företaget",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResearchingLead(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, field: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopiedField(field);
|
||||||
|
setTimeout(() => setCopiedField(null), 2000);
|
||||||
|
toast({
|
||||||
|
title: "Kopierat!",
|
||||||
|
description: `${field} kopierat till urklipp`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getEmployeeCountBadge = (count: number) => {
|
const getEmployeeCountBadge = (count: number) => {
|
||||||
if (count >= 100) return "bg-green-100 text-green-800";
|
if (count >= 100) return "bg-green-100 text-green-800";
|
||||||
if (count >= 50) return "bg-blue-100 text-blue-800";
|
if (count >= 50) return "bg-blue-100 text-blue-800";
|
||||||
@@ -65,6 +133,7 @@ export function LeadsGenerator() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -74,7 +143,7 @@ export function LeadsGenerator() {
|
|||||||
AI Kundprospektering
|
AI Kundprospektering
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Låt AI hitta potentiella B2B-kunder i Storstockholm
|
Generera leads och skapa personliga mejl med AI-driven research
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={generateLeads} disabled={isLoading}>
|
<Button onClick={generateLeads} disabled={isLoading}>
|
||||||
@@ -124,6 +193,7 @@ export function LeadsGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Varför bra kund</TableHead>
|
<TableHead>Varför bra kund</TableHead>
|
||||||
|
<TableHead className="text-right">Åtgärd</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -142,6 +212,26 @@ export function LeadsGenerator() {
|
|||||||
<TableCell className="max-w-xs text-sm text-muted-foreground">
|
<TableCell className="max-w-xs text-sm text-muted-foreground">
|
||||||
{lead.reason}
|
{lead.reason}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => researchCompany(lead)}
|
||||||
|
disabled={researchingLead === lead.company_name}
|
||||||
|
>
|
||||||
|
{researchingLead === lead.company_name ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
Researchar...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-3 w-3 mr-1" />
|
||||||
|
Research & Mejl
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -155,5 +245,84 @@ export function LeadsGenerator() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!emailDialog} onOpenChange={() => setEmailDialog(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Personligt mejl för {emailDialog?.company_name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
AI-genererat mejl baserat på företagsresearch
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{emailDialog && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{emailDialog.research_summary && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Research-sammanfattning:</p>
|
||||||
|
<p className="text-sm">{emailDialog.research_summary.substring(0, 300)}...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium">Ämnesrad:</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => copyToClipboard(emailDialog.email.subject, "Ämnesrad")}
|
||||||
|
>
|
||||||
|
{copiedField === "Ämnesrad" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-background border rounded-md">
|
||||||
|
<p className="font-medium">{emailDialog.email.subject}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium">Mejltext:</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => copyToClipboard(emailDialog.email.body, "Mejltext")}
|
||||||
|
>
|
||||||
|
{copiedField === "Mejltext" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-background border rounded-md whitespace-pre-wrap">
|
||||||
|
{emailDialog.email.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailDialog.email.personalization_points?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">Personaliseringspunkter:</label>
|
||||||
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
{emailDialog.email.personalization_points.map((point, i) => (
|
||||||
|
<li key={i}>{point}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
project_id = "teeojvlqulhghditoakc"
|
project_id = "teeojvlqulhghditoakc"
|
||||||
|
|
||||||
|
[functions.generate-leads]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.research-company]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.send-contact-email]
|
||||||
|
verify_jwt = false
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CompanyResearchRequest {
|
||||||
|
company_name: string;
|
||||||
|
district: string;
|
||||||
|
industry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY");
|
||||||
|
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||||
|
|
||||||
|
if (!FIRECRAWL_API_KEY) {
|
||||||
|
throw new Error("FIRECRAWL_API_KEY is not configured");
|
||||||
|
}
|
||||||
|
if (!LOVABLE_API_KEY) {
|
||||||
|
throw new Error("LOVABLE_API_KEY is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { company_name, district, industry }: CompanyResearchRequest = await req.json();
|
||||||
|
|
||||||
|
if (!company_name) {
|
||||||
|
throw new Error("company_name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Researching company: ${company_name}`);
|
||||||
|
|
||||||
|
// Step 1: Search for company website using Firecrawl
|
||||||
|
const searchResponse = await fetch("https://api.firecrawl.dev/v1/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${FIRECRAWL_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `${company_name} ${district} Stockholm företag`,
|
||||||
|
limit: 3,
|
||||||
|
scrapeOptions: {
|
||||||
|
formats: ["markdown"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
const errorText = await searchResponse.text();
|
||||||
|
console.error("Firecrawl search error:", errorText);
|
||||||
|
throw new Error(`Firecrawl search failed: ${searchResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchData = await searchResponse.json();
|
||||||
|
const companyInfo = searchData.data?.slice(0, 2).map((result: any) => result.markdown || result.description).join("\n\n") || "";
|
||||||
|
|
||||||
|
console.log(`Found company info: ${companyInfo.substring(0, 200)}...`);
|
||||||
|
|
||||||
|
// Step 2: Generate personalized email using Lovable AI
|
||||||
|
const emailResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "google/gemini-3-flash-preview",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `Du är en erfaren B2B-säljare för ett bageri i Stockholm som levererar färska kakor till företag.
|
||||||
|
|
||||||
|
Din uppgift är att skriva ett personligt och engagerande kall-mejl till potentiella kunder.
|
||||||
|
|
||||||
|
Regler:
|
||||||
|
- Håll mejlet kort (max 150 ord)
|
||||||
|
- Var personlig och referera till specifik info om företaget
|
||||||
|
- Nämn att vi levererar färska kakor varje vecka
|
||||||
|
- Föreslå ett möte eller provleverans
|
||||||
|
- Avsluta med en tydlig call-to-action
|
||||||
|
- Skriv på svenska
|
||||||
|
- Var professionell men varm i tonen`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Skriv ett personligt kall-mejl till:
|
||||||
|
Företag: ${company_name}
|
||||||
|
Bransch: ${industry}
|
||||||
|
Område: ${district}
|
||||||
|
|
||||||
|
Information om företaget:
|
||||||
|
${companyInfo || "Ingen specifik information hittad, använd generella branschinsikter."}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "generate_email",
|
||||||
|
description: "Genererar ett personligt kall-mejl",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
subject: { type: "string", description: "Mejlets ämnesrad" },
|
||||||
|
body: { type: "string", description: "Mejlets innehåll" },
|
||||||
|
personalization_points: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Punkter som gör mejlet personligt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["subject", "body", "personalization_points"],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tool_choice: { type: "function", function: { name: "generate_email" } }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailResponse.ok) {
|
||||||
|
if (emailResponse.status === 429) {
|
||||||
|
return new Response(JSON.stringify({ error: "Rate limit överskriden, försök igen senare." }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (emailResponse.status === 402) {
|
||||||
|
return new Response(JSON.stringify({ error: "Krediter slut, vänligen fyll på." }), {
|
||||||
|
status: 402,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const errorText = await emailResponse.text();
|
||||||
|
console.error("AI gateway error:", emailResponse.status, errorText);
|
||||||
|
throw new Error(`AI gateway error: ${emailResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData = await emailResponse.json();
|
||||||
|
const toolCall = emailData.choices?.[0]?.message?.tool_calls?.[0];
|
||||||
|
|
||||||
|
if (!toolCall || toolCall.function.name !== "generate_email") {
|
||||||
|
throw new Error("Unexpected AI response format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailContent = JSON.parse(toolCall.function.arguments);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
company_name,
|
||||||
|
research_summary: companyInfo.substring(0, 500),
|
||||||
|
email: emailContent
|
||||||
|
}), {
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error researching company:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user