forked from sagnik/Project_Velocity
feat: Complete code integration of modules (#18)
The complete code integration is done. Co-authored-by: Sagnik <sagnik7896@gmail.com> Reviewed-on: sagnik/Project_Velocity#18
This commit is contained in:
@@ -11,6 +11,7 @@ import { Inventory } from '@/components/modules/Inventory';
|
||||
import { Settings } from '@/components/modules/Settings';
|
||||
import { Catalyst } from '@/components/modules/Catalyst';
|
||||
import { NotificationCenter } from '@/components/layout/NotificationCenter';
|
||||
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
|
||||
import type { ModuleId } from '@/types';
|
||||
|
||||
import {
|
||||
@@ -75,6 +76,7 @@ function RouteModuleSync() {
|
||||
|
||||
function MainLayout() {
|
||||
const { activeModule, setActiveModule, sidebarExpanded, logout } = useStore();
|
||||
useCrmBootstrap();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMarketingStore } from '@/store/useMarketingStore';
|
||||
import { useCurrency } from '@/store/useCurrencyStore';
|
||||
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
|
||||
import { GroundTruthPicker } from './GroundTruthPicker';
|
||||
import { CatalystMarketingTab } from './CatalystMarketingTab';
|
||||
import type { GroundTruthSelection } from './GroundTruthPicker';
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────────────────────────
|
||||
@@ -936,13 +937,14 @@ function WarRoom() {
|
||||
// Tab Bar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room';
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
|
||||
const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
||||
{ id: 'studio', label: 'The Studio', icon: Clapperboard },
|
||||
{ id: 'command', label: 'Campaign Command', icon: Megaphone },
|
||||
{ id: 'intelligence', label: 'Intelligence & ROI', icon: BarChart3 },
|
||||
{ id: 'war-room', label: 'War Room', icon: Globe },
|
||||
{ id: 'marketing', label: 'Marketing', icon: TrendingUp },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -957,6 +959,7 @@ export function Catalyst() {
|
||||
'command': <CampaignCommand />,
|
||||
'intelligence': <IntelligenceROI />,
|
||||
'war-room': <WarRoom />,
|
||||
'marketing': <CatalystMarketingTab />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
263
app/src/components/modules/CatalystMarketingTab.tsx
Normal file
263
app/src/components/modules/CatalystMarketingTab.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, BarChart3, DatabaseZap, Megaphone, RefreshCw, Sparkles } from 'lucide-react';
|
||||
|
||||
import {
|
||||
getCatalystCampaigns,
|
||||
getLeadDemographics,
|
||||
getSentimentScatter,
|
||||
seedSyntheticLeads,
|
||||
type LeadDemographics,
|
||||
type MarketingCampaignSummary,
|
||||
type ScatterDataPoint,
|
||||
} from '@/lib/api';
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('en-AE', {
|
||||
style: 'currency',
|
||||
currency: 'AED',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
icon: typeof Activity;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-[rgba(8,10,18,0.82)] p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-blue-400/20 bg-blue-500/10">
|
||||
<Icon className="h-4 w-4 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-white/50 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryMetric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">{label}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalystMarketingTab() {
|
||||
const [campaigns, setCampaigns] = useState<MarketingCampaignSummary[]>([]);
|
||||
const [scatter, setScatter] = useState<ScatterDataPoint[]>([]);
|
||||
const [demographics, setDemographics] = useState<LeadDemographics | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [campaignRows, scatterRows, demographicRows] = await Promise.all([
|
||||
getCatalystCampaigns(),
|
||||
getSentimentScatter(),
|
||||
getLeadDemographics(),
|
||||
]);
|
||||
if (!active) return;
|
||||
setCampaigns(campaignRows);
|
||||
setScatter(scatterRows);
|
||||
setDemographics(demographicRows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load marketing intelligence');
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalBudget = campaigns.reduce((sum, campaign) => sum + campaign.budget, 0);
|
||||
const totalSpent = campaigns.reduce((sum, campaign) => sum + campaign.spent, 0);
|
||||
const totalLeads = scatter.length;
|
||||
const whales = scatter.filter((item) => item.qualification === 'WHALE').length;
|
||||
const avgSentiment = totalLeads
|
||||
? Math.round(scatter.reduce((sum, item) => sum + item.sentiment_score, 0) / totalLeads)
|
||||
: 0;
|
||||
return { totalBudget, totalSpent, totalLeads, whales, avgSentiment };
|
||||
}, [campaigns, scatter]);
|
||||
|
||||
const handleSeed = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
await seedSyntheticLeads(100);
|
||||
const [scatterRows, demographicRows] = await Promise.all([
|
||||
getSentimentScatter(),
|
||||
getLeadDemographics(),
|
||||
]);
|
||||
setScatter(scatterRows);
|
||||
setDemographics(demographicRows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Synthetic seed failed');
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionCard
|
||||
title="Integrated Marketing Surface"
|
||||
subtitle="Sourik-style campaign intelligence stacked inside the root Catalyst shell."
|
||||
icon={Sparkles}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-5">
|
||||
<SummaryMetric label="Campaigns" value={campaigns.length} />
|
||||
<SummaryMetric label="Tracked Leads" value={totals.totalLeads} />
|
||||
<SummaryMetric label="Whales" value={totals.whales} />
|
||||
<SummaryMetric label="Avg Sentiment" value={totals.avgSentiment} />
|
||||
<SummaryMetric label="Active Spend" value={formatMoney(totals.totalSpent)} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Campaign Manager"
|
||||
subtitle="Unified Meta-first campaign strip, vertically ported into the Marketing sub-tab."
|
||||
icon={Megaphone}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-white/50">Loading campaign intelligence…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaigns.map((campaign) => (
|
||||
<div key={campaign.id} className="rounded-xl border border-white/8 bg-white/5 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{campaign.name}</div>
|
||||
<div className="mt-1 text-[11px] uppercase tracking-wide text-white/45">
|
||||
{campaign.platform} · {campaign.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-white/65">
|
||||
<div>Budget {formatMoney(campaign.budget)}</div>
|
||||
<div>Spent {formatMoney(campaign.spent)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-white/65 md:grid-cols-4">
|
||||
<div>Impressions {campaign.impressions.toLocaleString()}</div>
|
||||
<div>Clicks {campaign.clicks.toLocaleString()}</div>
|
||||
<div>Conversions {campaign.conversions.toLocaleString()}</div>
|
||||
<div>
|
||||
CTR {campaign.impressions ? ((campaign.clicks / campaign.impressions) * 100).toFixed(2) : '0.00'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Lead Intelligence Feed"
|
||||
subtitle="Live analytics from the root CRM routes."
|
||||
icon={BarChart3}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<div className="space-y-2">
|
||||
{scatter.slice(0, 14).map((lead) => (
|
||||
<div key={lead.id} className="rounded-xl border border-white/8 bg-white/5 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{lead.name}</div>
|
||||
<div className="text-xs text-white/50">
|
||||
{lead.qualification} · {lead.kanban_status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-white/65">
|
||||
<div>Score {lead.score}</div>
|
||||
<div>Sentiment {lead.sentiment_score}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && scatter.length === 0 && <p className="text-sm text-white/50">No lead analytics available yet.</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/45">Lead Sources</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(demographics?.by_source ?? []).map((row) => (
|
||||
<div key={row.source} className="flex items-center justify-between text-sm text-white/80">
|
||||
<span>{row.source}</span>
|
||||
<span>{row.lead_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/45">Qualification Mix</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(demographics?.by_qualification ?? []).map((row) => (
|
||||
<div key={row.qualification} className="flex items-center justify-between text-sm text-white/80">
|
||||
<span>{row.qualification}</span>
|
||||
<span>{row.lead_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Platform Status and Verification"
|
||||
subtitle="Production-readiness controls kept inside the same vertical marketing surface."
|
||||
icon={DatabaseZap}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
|
||||
<div className="mb-1 text-white font-medium">CRM Analytics</div>
|
||||
<div>{totals.totalLeads > 0 ? 'Live data available' : 'No seeded verification data yet'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
|
||||
<div className="mb-1 text-white font-medium">Catalyst Contracts</div>
|
||||
<div>{campaigns.length > 0 ? 'Marketing tab wired to root endpoints' : 'Campaign summary unavailable'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-white/5 p-4 text-sm text-white/75">
|
||||
<div className="mb-1 text-white font-medium">Spend Capacity</div>
|
||||
<div>Total budget {formatMoney(totals.totalBudget)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSeed()}
|
||||
disabled={seeding}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-blue-400/25 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200 disabled:opacity-50"
|
||||
>
|
||||
{seeding ? <RefreshCw className="h-4 w-4 animate-spin" /> : <DatabaseZap className="h-4 w-4" />}
|
||||
Seed 100 Synthetic Leads
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mt-4 text-sm text-red-300">{error}</p>}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
app/src/hooks/useCrmBootstrap.ts
Normal file
46
app/src/hooks/useCrmBootstrap.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { getChatLogs, getLeads } from '@/lib/api';
|
||||
import { mapLeadRecordToStoreLead } from '@/lib/crmMappers';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
export function useCrmBootstrap() {
|
||||
const { setLeads, replaceMessages } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
const leads = await getLeads();
|
||||
if (cancelled) return;
|
||||
setLeads(leads.map(mapLeadRecordToStoreLead));
|
||||
|
||||
const messageEntries = await Promise.all(
|
||||
leads.slice(0, 25).map(async (lead) => {
|
||||
const logs = await getChatLogs(lead.id);
|
||||
return [
|
||||
lead.id,
|
||||
logs.map((log): ChatMessage => ({
|
||||
id: log.id,
|
||||
sender: log.sender === 'lead' ? 'user' : 'oracle',
|
||||
content: log.content,
|
||||
timestamp: new Date(log.created_at ?? Date.now()),
|
||||
})),
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
replaceMessages(Object.fromEntries(messageEntries));
|
||||
}
|
||||
} catch {
|
||||
// Keep the current in-app fallback state if the CRM backend is unreachable.
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [replaceMessages, setLeads]);
|
||||
}
|
||||
@@ -17,3 +17,119 @@ export const API_URL = (
|
||||
).replace(/\/$/, '');
|
||||
|
||||
export const WS_URL = API_URL.replace(/^http/, 'ws');
|
||||
|
||||
export interface ScatterDataPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
sentiment_score: number;
|
||||
response_time_ms: number;
|
||||
score: number;
|
||||
qualification: string;
|
||||
kanban_status: string;
|
||||
}
|
||||
|
||||
export interface LeadRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source: string;
|
||||
notes: string;
|
||||
qualification: string;
|
||||
score: number;
|
||||
kanban_status: string;
|
||||
stage: string;
|
||||
budget: string;
|
||||
unit_interest: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LeadDemographics {
|
||||
by_source: Array<{ source: string; lead_count: number; avg_score: number }>;
|
||||
by_qualification: Array<{ qualification: string; lead_count: number }>;
|
||||
}
|
||||
|
||||
export interface ChatLogRecord {
|
||||
id: string;
|
||||
lead_id: string;
|
||||
sender: string;
|
||||
channel: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MarketingCampaignSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: 'meta' | 'google';
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
budget: number;
|
||||
spent: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestWrappedData<T>(path: string): Promise<T> {
|
||||
const payload = await requestJson<{ data: T }>(path);
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function getSentimentScatter(): Promise<ScatterDataPoint[]> {
|
||||
return requestJson<ScatterDataPoint[]>('/api/analytics/sentiment-scatter');
|
||||
}
|
||||
|
||||
export async function getCatalystCampaigns(): Promise<MarketingCampaignSummary[]> {
|
||||
return requestWrappedData<MarketingCampaignSummary[]>('/api/catalyst/campaigns');
|
||||
}
|
||||
|
||||
export async function getLeads(): Promise<LeadRecord[]> {
|
||||
const payload = await requestJson<{ data: LeadRecord[] }>('/api/leads');
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function getLead(leadId: string): Promise<LeadRecord> {
|
||||
return requestWrappedData<LeadRecord>(`/api/leads/${leadId}`);
|
||||
}
|
||||
|
||||
export async function getKanbanBoard() {
|
||||
return requestWrappedData<Array<{ status: string; stage: string; count: number; items: LeadRecord[] }>>('/api/kanban/board');
|
||||
}
|
||||
|
||||
export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
|
||||
const suffix = leadId ? `?lead_id=${encodeURIComponent(leadId)}` : '';
|
||||
return requestWrappedData<ChatLogRecord[]>(`/api/chat-logs${suffix}`);
|
||||
}
|
||||
|
||||
export async function getLeadDemographics(): Promise<LeadDemographics> {
|
||||
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
|
||||
}
|
||||
|
||||
export async function seedSyntheticLeads(count = 100): Promise<{ seeded: number; chat_logs_seeded: number; batch: string }> {
|
||||
const response = await fetch(`${API_URL}/api/leads/seed-synthetic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Seed request failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json() as { data: { seeded: number; chat_logs_seeded: number; batch: string } };
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
122
app/src/lib/crmMappers.ts
Normal file
122
app/src/lib/crmMappers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { ChatLogRecord, LeadRecord } from '@/lib/api';
|
||||
import type { Lead } from '@/types';
|
||||
import type { LeadBadge, LeadTag, LeadSource, Message, MessageSender, PipelineStage, SentimentLog } from '@/types/crm';
|
||||
|
||||
const TAG_MAP: Record<string, LeadTag> = {
|
||||
whale: '#CashBuyer',
|
||||
potential: '#Investor',
|
||||
hot: '#EndUser',
|
||||
};
|
||||
|
||||
export function mapLeadRecordToStoreLead(record: LeadRecord): Lead {
|
||||
const qualification = record.qualification.toLowerCase() as Lead['qualification'];
|
||||
const status = record.stage === 'closed'
|
||||
? 'closed'
|
||||
: record.stage === 'qualified' || record.stage === 'negotiation'
|
||||
? 'qualified'
|
||||
: record.score >= 75
|
||||
? 'hot'
|
||||
: record.stage === 'new'
|
||||
? 'new'
|
||||
: 'engaged';
|
||||
const tags = Array.isArray(record.metadata?.tags) ? (record.metadata.tags as string[]) : [];
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
phone: record.phone ?? '',
|
||||
source: mapSource(record.source),
|
||||
status,
|
||||
lastMessage: record.notes || 'No conversation summary yet.',
|
||||
lastActive: new Date(record.updated_at ?? record.created_at ?? Date.now()),
|
||||
unreadCount: 0,
|
||||
qualification: qualification === 'tire_kicker' || qualification === 'potential' || qualification === 'whale'
|
||||
? qualification
|
||||
: 'potential',
|
||||
budget: record.budget,
|
||||
interest: record.unit_interest,
|
||||
quantumDynamicsScore: record.score,
|
||||
tags: tags.length > 0 ? tags : [record.qualification],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLeadRecordToOracleLead(record: LeadRecord, chatLogs: ChatLogRecord[]): import('@/types/crm').Lead {
|
||||
const badge = mapBadge(record.qualification);
|
||||
const tags = mapOracleTags(record.qualification, record.metadata);
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
phone: record.phone ?? '',
|
||||
stage: mapPipelineStage(record.stage),
|
||||
oracleScore: record.score,
|
||||
badge,
|
||||
tags,
|
||||
source: mapSource(record.source),
|
||||
budget: record.budget,
|
||||
unitInterest: record.unit_interest,
|
||||
profileImageUrl: `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(record.name)}`,
|
||||
visitedShowroom: record.stage === 'site_visit' || record.stage === 'negotiation' || record.stage === 'closed',
|
||||
inShowroomNow: record.stage === 'site_visit',
|
||||
messages: chatLogs.map(mapChatLogToOracleMessage),
|
||||
sentimentLog: buildSentimentLog(record.score, record.stage),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSource(source: string): LeadSource {
|
||||
if (source === 'walkin' || source === 'website' || source === 'whatsapp') return source;
|
||||
return 'website';
|
||||
}
|
||||
|
||||
function mapPipelineStage(stage: string): PipelineStage {
|
||||
const normalized = stage.toLowerCase();
|
||||
if (normalized === 'new' || normalized === 'new_inquiries') return 'new_inquiries';
|
||||
if (normalized === 'qualified' || normalized === 'qualifying') return 'qualified';
|
||||
if (normalized === 'site_visit') return 'site_visit';
|
||||
if (normalized === 'negotiation') return 'negotiation';
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
function mapBadge(qualification: string): LeadBadge | undefined {
|
||||
const normalized = qualification.toLowerCase();
|
||||
if (normalized === 'whale') return 'whale';
|
||||
if (normalized === 'hot' || normalized === 'potential') return 'hot';
|
||||
if (normalized === 'tire_kicker') return 'tire_kicker';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapOracleTags(qualification: string, metadata: Record<string, unknown>): LeadTag[] {
|
||||
const mapped = TAG_MAP[qualification.toLowerCase()];
|
||||
const rawTags = Array.isArray(metadata?.tags) ? metadata.tags as string[] : [];
|
||||
const canonical = rawTags.includes('#CashBuyer') || mapped === '#CashBuyer'
|
||||
? '#CashBuyer'
|
||||
: rawTags.includes('#EndUser') || mapped === '#EndUser'
|
||||
? '#EndUser'
|
||||
: '#Investor';
|
||||
return [canonical];
|
||||
}
|
||||
|
||||
function mapChatLogToOracleMessage(log: ChatLogRecord): Message {
|
||||
return {
|
||||
id: log.id,
|
||||
sender: mapSender(log.sender),
|
||||
content: log.content,
|
||||
createdAt: log.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSender(sender: string): MessageSender {
|
||||
if (sender === 'lead' || sender === 'oracle' || sender === 'system') return sender;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function buildSentimentLog(score: number, stage: string): SentimentLog[] {
|
||||
const base = Math.max(20, score - 18);
|
||||
const labels = stage === 'site_visit'
|
||||
? ['Entry', 'Showroom peak', 'Pricing review']
|
||||
: ['Discovery', 'Qualification', 'Follow-up'];
|
||||
return labels.map((label, index) => ({
|
||||
id: `${stage}-${index}`,
|
||||
at: `${10 + index}:0${index}`,
|
||||
score: Math.min(100, base + index * 9),
|
||||
note: label,
|
||||
}));
|
||||
}
|
||||
@@ -146,7 +146,7 @@ interface MarketingState {
|
||||
adInsights: AdInsight[];
|
||||
liveEvents: LiveOptimizationEvent[];
|
||||
settings: CatalystSettings;
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room';
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
|
||||
// Actions
|
||||
addCampaign: (campaign: Campaign) => void;
|
||||
|
||||
@@ -37,6 +37,8 @@ interface OracleState {
|
||||
activeLeadId: string | null;
|
||||
messages: Record<string, ChatMessage[]>;
|
||||
isOracleThinking: boolean;
|
||||
setLeads: (leads: Lead[]) => void;
|
||||
replaceMessages: (messages: Record<string, ChatMessage[]>) => void;
|
||||
setActiveLead: (leadId: string | null) => void;
|
||||
addMessage: (leadId: string, message: ChatMessage) => void;
|
||||
setOracleThinking: (thinking: boolean) => void;
|
||||
@@ -274,6 +276,8 @@ export const useStore = create<StoreState>()(
|
||||
activeLeadId: null,
|
||||
messages: mockMessages,
|
||||
isOracleThinking: false,
|
||||
setLeads: (leads) => set({ leads }),
|
||||
replaceMessages: (messages) => set({ messages }),
|
||||
setActiveLead: (leadId) => set({ activeLeadId: leadId }),
|
||||
addMessage: (leadId, message) => set((state) => ({
|
||||
messages: {
|
||||
|
||||
Reference in New Issue
Block a user