feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
|
||||
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
|
||||
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
|
||||
Activity, Check, Link2,
|
||||
Activity, Check, Link2, WandSparkles,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { useCurrency } from '@/store/useCurrencyStore';
|
||||
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
|
||||
import { GroundTruthPicker } from './GroundTruthPicker';
|
||||
import { CatalystMarketingTab } from './CatalystMarketingTab';
|
||||
import { CatalystDreamWeaverTab } from './CatalystDreamWeaverTab';
|
||||
import type { GroundTruthSelection } from './GroundTruthPicker';
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────────────────────────
|
||||
@@ -917,7 +918,7 @@ function WarRoom() {
|
||||
// Tab Bar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
|
||||
|
||||
const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
||||
{ id: 'studio', label: 'The Studio', icon: Clapperboard },
|
||||
@@ -925,6 +926,7 @@ const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
||||
{ id: 'intelligence', label: 'Intelligence & ROI', icon: BarChart3 },
|
||||
{ id: 'war-room', label: 'War Room', icon: Globe },
|
||||
{ id: 'marketing', label: 'Marketing', icon: TrendingUp },
|
||||
{ id: 'dream-weaver', label: 'Dream Weaver', icon: WandSparkles },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -940,6 +942,7 @@ export function Catalyst() {
|
||||
'intelligence': <IntelligenceROI />,
|
||||
'war-room': <WarRoom />,
|
||||
'marketing': <CatalystMarketingTab />,
|
||||
'dream-weaver': <CatalystDreamWeaverTab />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -998,8 +1001,8 @@ export function Catalyst() {
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Live Optimization Feed — always visible */}
|
||||
<LiveOptimizationFeed />
|
||||
{/* Live Optimization Feed — hidden on Dream Weaver because generation has its own status surface. */}
|
||||
{activeTab !== 'dream-weaver' && <LiveOptimizationFeed />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal file
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Home,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Sparkles,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { useMarketingStore } from '@/store/useMarketingStore';
|
||||
import {
|
||||
DREAM_WEAVER_URL,
|
||||
checkDreamWeaverHealth,
|
||||
fetchDreamWeaverResult,
|
||||
getDreamWeaverStatus,
|
||||
submitDreamWeaverJob,
|
||||
type DreamWeaverHealth,
|
||||
type DreamWeaverJobResponse,
|
||||
type DreamWeaverStatusResponse,
|
||||
} from '@/lib/dreamWeaverApi';
|
||||
|
||||
const GLASS: CSSProperties = {
|
||||
background: 'rgba(8, 10, 18, 0.82)',
|
||||
border: '1px solid rgba(59,130,246,0.14)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
|
||||
};
|
||||
|
||||
const INNER: CSSProperties = {
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
};
|
||||
|
||||
const ROOM_TYPES = [
|
||||
{ id: 'bedroom', label: 'Bedroom' },
|
||||
{ id: 'living_room', label: 'Living Room' },
|
||||
{ id: 'bathroom', label: 'Bathroom' },
|
||||
{ id: 'kitchen', label: 'Kitchen' },
|
||||
{ id: 'dining_room', label: 'Dining Room' },
|
||||
{ id: 'home_office', label: 'Office' },
|
||||
{ id: 'hallway', label: 'Hallway' },
|
||||
{ id: 'balcony', label: 'Balcony' },
|
||||
] as const;
|
||||
|
||||
type ProcessingState = 'idle' | 'checking' | 'submitting' | 'rendering' | 'downloading';
|
||||
|
||||
interface DreamWeaverOutput {
|
||||
id: string;
|
||||
roomLabel: string;
|
||||
keywords: string;
|
||||
imageUrl: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isReadyStatus(status: DreamWeaverStatusResponse) {
|
||||
const normalized = status.status?.toLowerCase() ?? '';
|
||||
return Boolean(status.ready) || ['ready', 'completed', 'complete', 'succeeded', 'success', 'finished'].includes(normalized);
|
||||
}
|
||||
|
||||
function isFailedStatus(status: DreamWeaverStatusResponse) {
|
||||
const normalized = status.status?.toLowerCase() ?? '';
|
||||
return ['failed', 'error', 'cancelled', 'canceled'].includes(normalized);
|
||||
}
|
||||
|
||||
function statusLabel(state: ProcessingState, health: DreamWeaverHealth | null) {
|
||||
if (state === 'checking') return 'Checking gateway';
|
||||
if (state === 'submitting') return 'Submitting render';
|
||||
if (state === 'rendering') return 'ComfyUI rendering';
|
||||
if (state === 'downloading') return 'Fetching result';
|
||||
if (!health) return 'Gateway unknown';
|
||||
if (health.online && health.routeMounted && health.comfyuiOnline === false) return 'Gateway online · ComfyUI offline';
|
||||
if (health.online && health.routeMounted && health.comfyuiOnline && health.checkpointReady === false) return 'Gateway online · Model missing';
|
||||
return health.online && health.routeMounted ? 'Gateway online' : 'Gateway offline';
|
||||
}
|
||||
|
||||
function ResultActions({ output }: { output: DreamWeaverOutput }) {
|
||||
function downloadResult() {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = output.imageUrl;
|
||||
anchor.download = `dream-weaver-${output.id}.png`;
|
||||
anchor.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadResult}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Download generated image"
|
||||
>
|
||||
<Download className="w-4 h-4 text-white/75" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(output.imageUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Open generated image"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-white/75" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalystDreamWeaverTab() {
|
||||
const { pushLiveEvent } = useMarketingStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState<string | null>(null);
|
||||
const [selectedRoomType, setSelectedRoomType] = useState<(typeof ROOM_TYPES)[number]['id']>('bedroom');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
|
||||
const [processingState, setProcessingState] = useState<ProcessingState>('checking');
|
||||
const [progress, setProgress] = useState('Checking Dream Weaver gateway...');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentOutput, setCurrentOutput] = useState<DreamWeaverOutput | null>(null);
|
||||
const [history, setHistory] = useState<DreamWeaverOutput[]>([]);
|
||||
|
||||
const isProcessing = processingState !== 'idle' && processingState !== 'checking';
|
||||
const roomLabel = ROOM_TYPES.find((room) => room.id === selectedRoomType)?.label ?? 'Bedroom';
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHealth();
|
||||
return () => {
|
||||
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrlsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function rememberObjectUrl(blobOrFile: Blob) {
|
||||
const url = URL.createObjectURL(blobOrFile);
|
||||
objectUrlsRef.current.add(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function setSourceFromFile(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Dream Weaver needs a source room image.');
|
||||
return;
|
||||
}
|
||||
const previewUrl = rememberObjectUrl(file);
|
||||
setSourceFile(file);
|
||||
setSourcePreview(previewUrl);
|
||||
setCurrentOutput(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSourceFromFile(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
setProcessingState('checking');
|
||||
setProgress('Checking Dream Weaver gateway...');
|
||||
const nextHealth = await checkDreamWeaverHealth();
|
||||
setHealth(nextHealth);
|
||||
setProcessingState('idle');
|
||||
setProgress(nextHealth.online && nextHealth.routeMounted
|
||||
? nextHealth.comfyuiOnline === false
|
||||
? `Gateway is online and the Dream Weaver route is mounted. ComfyUI is offline${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}.`
|
||||
: nextHealth.checkpointReady === false
|
||||
? `ComfyUI is online${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}, but no checkpoint model is installed. Hydrate RealVisXL into ComfyUI/models/checkpoints.`
|
||||
: 'Gateway is online and the Dream Weaver route is mounted.'
|
||||
: nextHealth.detail ?? 'Dream Weaver gateway is not reachable.');
|
||||
}
|
||||
|
||||
async function pollUntilReady(job: DreamWeaverJobResponse) {
|
||||
let latestResultUrl = job.result_url;
|
||||
for (let attempt = 1; attempt <= 150; attempt += 1) {
|
||||
const status = await getDreamWeaverStatus(job);
|
||||
latestResultUrl = status.result_url ?? latestResultUrl;
|
||||
setProgress(status.status ? `Render ${status.status} · poll ${attempt}/150` : `Render queued · poll ${attempt}/150`);
|
||||
|
||||
if (isReadyStatus(status)) {
|
||||
return latestResultUrl;
|
||||
}
|
||||
if (isFailedStatus(status) || status.error) {
|
||||
throw new Error(status.error ?? `Dream Weaver render ${status.status ?? 'failed'}.`);
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error('Dream Weaver timed out after 5 minutes.');
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!sourceFile || isProcessing) return;
|
||||
setError(null);
|
||||
setCurrentOutput(null);
|
||||
|
||||
try {
|
||||
setProcessingState('submitting');
|
||||
setProgress(`Submitting ${roomLabel.toLowerCase()} staging request...`);
|
||||
const job = await submitDreamWeaverJob({
|
||||
image: sourceFile,
|
||||
roomType: selectedRoomType,
|
||||
keywords,
|
||||
});
|
||||
|
||||
setProcessingState('rendering');
|
||||
setProgress(`Job ${job.job_id} accepted. Waiting for ComfyUI output...`);
|
||||
const resultUrl = await pollUntilReady(job);
|
||||
|
||||
setProcessingState('downloading');
|
||||
setProgress('Fetching generated image...');
|
||||
const resultBlob = await fetchDreamWeaverResult(job.job_id, resultUrl);
|
||||
const imageUrl = rememberObjectUrl(resultBlob);
|
||||
const output: DreamWeaverOutput = {
|
||||
id: job.job_id,
|
||||
roomLabel,
|
||||
keywords: keywords.trim(),
|
||||
imageUrl,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentOutput(output);
|
||||
setHistory((items) => [output, ...items].slice(0, 6));
|
||||
setProgress('Dream Weaver render complete.');
|
||||
pushLiveEvent({
|
||||
id: `dw-${job.job_id}-${Date.now()}`,
|
||||
type: 'create',
|
||||
campaignName: 'Dream Weaver',
|
||||
message: `${roomLabel} staging render completed.`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Dream Weaver render failed.';
|
||||
setError(message);
|
||||
setProgress(message);
|
||||
pushLiveEvent({
|
||||
id: `dw-error-${Date.now()}`,
|
||||
type: 'alert',
|
||||
campaignName: 'Dream Weaver',
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setProcessingState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
|
||||
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
|
||||
<WandSparkles className="w-4 h-4 text-blue-400" /> Dream Weaver
|
||||
</h3>
|
||||
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Room image transformation pipeline using the same Dream Weaver gateway as the iPad app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs" style={INNER}>
|
||||
<span className={`h-2 w-2 rounded-full ${health?.online && health.routeMounted ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-white/75">{statusLabel(processingState, health)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshHealth()}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Check Dream Weaver gateway"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-white/75 ${processingState === 'checking' ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.05, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-blue-400" /> Source Room
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Upload a ground-truth room photograph, choose the target room type, then add optional styling keywords.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) setSourceFromFile(file);
|
||||
}}
|
||||
className="relative w-full min-h-[300px] rounded-2xl overflow-hidden flex items-center justify-center text-left transition-colors hover:border-blue-400/40"
|
||||
style={{ ...INNER, background: sourcePreview ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.025)' }}
|
||||
>
|
||||
{sourcePreview ? (
|
||||
<img src={sourcePreview} alt="Selected source room" className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center px-6">
|
||||
<div className="h-12 w-12 rounded-2xl flex items-center justify-center" style={INNER}>
|
||||
<Upload className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Upload room image</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Click to browse or drop a photo here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-widest mb-2" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Room type
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{ROOM_TYPES.map((room) => {
|
||||
const selected = selectedRoomType === room.id;
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRoomType(room.id)}
|
||||
className="rounded-xl px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
background: selected ? 'rgba(59,130,246,0.18)' : 'rgba(255,255,255,0.04)',
|
||||
border: selected ? '1px solid rgba(59,130,246,0.38)' : '1px solid rgba(255,255,255,0.07)',
|
||||
color: selected ? '#fff' : 'rgba(226,232,240,0.72)',
|
||||
}}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{room.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium uppercase tracking-widest mb-2 block" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Keywords
|
||||
</label>
|
||||
<textarea
|
||||
value={keywords}
|
||||
onChange={(event) => setKeywords(event.target.value)}
|
||||
placeholder="gold, marble, luxury, soft daylight"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-xl px-3 py-2 text-sm text-white placeholder-white/20 outline-none focus:border-blue-400/50"
|
||||
style={INNER}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
className="rounded-xl p-3 flex items-start gap-2"
|
||||
style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)' }}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-200 leading-relaxed">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl p-3" style={INNER}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-white truncate">{progress}</p>
|
||||
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
||||
Gateway: {DREAM_WEAVER_URL}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generate()}
|
||||
disabled={!sourceFile || isProcessing || health?.routeMounted === false || health?.comfyuiOnline === false || health?.checkpointReady === false}
|
||||
className="h-11 px-5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
|
||||
>
|
||||
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.1, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<WandSparkles className="w-4 h-4 text-blue-400" /> Generated Staging
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
The result appears here as soon as the gateway marks the job ready.
|
||||
</p>
|
||||
</div>
|
||||
{currentOutput && <ResultActions output={currentOutput} />}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[460px] rounded-2xl overflow-hidden flex items-center justify-center" style={INNER}>
|
||||
{currentOutput ? (
|
||||
<img src={currentOutput.imageUrl} alt={`${currentOutput.roomLabel} Dream Weaver result`} className="absolute inset-0 h-full w-full object-contain bg-black" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center px-8">
|
||||
<div className="h-14 w-14 rounded-2xl flex items-center justify-center" style={INNER}>
|
||||
{isProcessing ? <Loader2 className="h-6 w-6 text-blue-400 animate-spin" /> : <WandSparkles className="h-6 w-6 text-blue-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{isProcessing ? 'Dream Weaver is rendering' : 'No generated image yet'}</p>
|
||||
<p className="text-xs mt-1 max-w-md" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Upload a source image and generate a staging render to populate this canvas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Recent renders
|
||||
</p>
|
||||
<span className="text-[11px]" style={{ color: 'rgba(148,163,184,0.45)' }}>{history.length}/6</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setCurrentOutput(item)}
|
||||
className="group rounded-xl overflow-hidden text-left transition-colors hover:border-blue-400/40"
|
||||
style={INNER}
|
||||
>
|
||||
<div className="aspect-[4/3] bg-black overflow-hidden">
|
||||
<img src={item.imageUrl} alt={item.roomLabel} className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-white">
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
{item.roomLabel}
|
||||
</div>
|
||||
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
{item.keywords || item.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.15, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" /> Gateway Contract
|
||||
</h3>
|
||||
<span className="text-xs font-medium" style={{ color: health?.routeMounted ? '#4ade80' : '#f87171' }}>
|
||||
{health?.routeMounted ? 'Route mounted' : 'Route not verified'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{['POST /dream-weaver', 'GET /dream-weaver/status/{job_id}', 'GET /dream-weaver/result/{job_id}'].map((endpoint) => (
|
||||
<div key={endpoint} className="rounded-xl p-3" style={INNER}>
|
||||
<p className="text-xs font-mono text-blue-200">{endpoint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
|
||||
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
|
||||
|
||||
@@ -75,10 +77,17 @@ export interface MarketingCampaignSummary {
|
||||
|
||||
async function requestJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: buildVelocityHeaders(undefined, false),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@ import type {
|
||||
Client360Snapshot,
|
||||
CrmOpportunityCard,
|
||||
CrmTask,
|
||||
CrmLeadStageUpdate,
|
||||
KanbanColumn,
|
||||
ImportBatchSummary,
|
||||
ImportProposal,
|
||||
ImportReviewDecision,
|
||||
QdScoreEntry,
|
||||
} from '@/types/crmTypes';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
return Object.fromEntries(buildVelocityHeaders(undefined, false).entries());
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
@@ -87,6 +87,23 @@ export async function fetchOpportunities(params?: {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateOpportunity(body: {
|
||||
opportunity_id: string;
|
||||
stage?: string;
|
||||
value?: number | null;
|
||||
probability?: number | null;
|
||||
expected_close_date?: string | null;
|
||||
next_action?: string | null;
|
||||
notes?: string | null;
|
||||
}): Promise<CrmOpportunityCard> {
|
||||
const { opportunity_id, ...payload } = body;
|
||||
const res = await apiFetch<{ status: string; data: CrmOpportunityCard }>(`/api/crm/opportunities/${opportunity_id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTasks(params?: {
|
||||
@@ -118,6 +135,23 @@ export async function createTask(body: {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateTask(body: {
|
||||
reminder_id: string;
|
||||
status: 'pending' | 'done' | 'snoozed' | 'cancelled';
|
||||
due_at?: string;
|
||||
notes?: string;
|
||||
}): Promise<CrmTask> {
|
||||
const res = await apiFetch<{ status: string; data: CrmTask }>(`/api/crm/tasks/${body.reminder_id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: body.status,
|
||||
due_at: body.due_at,
|
||||
notes: body.notes,
|
||||
}),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Kanban ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
|
||||
@@ -125,6 +159,21 @@ export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateLeadStage(body: {
|
||||
lead_id: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
}): Promise<CrmLeadStageUpdate> {
|
||||
const res = await apiFetch<{ status: string; data: CrmLeadStageUpdate }>(`/api/crm/leads/${body.lead_id}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: body.status,
|
||||
notes: body.notes,
|
||||
}),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── QD Scores ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchQdScore(personId: string): Promise<{
|
||||
|
||||
197
app/src/lib/dreamWeaverApi.ts
Normal file
197
app/src/lib/dreamWeaverApi.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
|
||||
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
|
||||
const LOCAL_DREAM_WEAVER_GATEWAY = 'http://127.0.0.1:8082';
|
||||
|
||||
export const DREAM_WEAVER_URL = (rawDreamWeaverBase && rawDreamWeaverBase.length > 0
|
||||
? rawDreamWeaverBase
|
||||
: import.meta.env.DEV
|
||||
? LOCAL_DREAM_WEAVER_GATEWAY
|
||||
: API_URL
|
||||
).replace(/\/$/, '');
|
||||
|
||||
export interface DreamWeaverHealth {
|
||||
online: boolean;
|
||||
routeMounted: boolean;
|
||||
status: string;
|
||||
comfyuiOnline?: boolean;
|
||||
comfyuiUrl?: string;
|
||||
checkpointReady?: boolean;
|
||||
checkpointCount?: number;
|
||||
availableCheckpoints?: string[];
|
||||
preferredCheckpoints?: string[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverJobResponse {
|
||||
job_id: string;
|
||||
status?: string;
|
||||
poll_url?: string;
|
||||
result_url?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverStatusResponse {
|
||||
status?: string;
|
||||
ready?: boolean;
|
||||
result_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SubmitDreamWeaverJobInput {
|
||||
image: File;
|
||||
roomType: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
function buildDreamWeaverHeaders(init?: HeadersInit): Headers {
|
||||
const headers = buildVelocityHeaders(init, false);
|
||||
if (rawDreamWeaverApiKey && !headers.has('X-Dream-Weaver-API-Key')) {
|
||||
headers.set('X-Dream-Weaver-API-Key', rawDreamWeaverApiKey);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function resolveDreamWeaverUrl(candidate: string | undefined, fallbackPath: string): string {
|
||||
const path = candidate && candidate.trim().length > 0 ? candidate.trim() : fallbackPath;
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return `${DREAM_WEAVER_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
const body = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null;
|
||||
if (typeof body?.detail === 'string') return body.detail;
|
||||
if (typeof body?.message === 'string') return body.message;
|
||||
if (typeof body?.error === 'string') return body.error;
|
||||
const text = await response.text().catch(() => '');
|
||||
return text.trim() || fallback;
|
||||
}
|
||||
|
||||
async function requestDreamWeaverJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: buildDreamWeaverHeaders(init?.headers),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver request failed: ${response.status}`));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function checkDreamWeaverHealth(): Promise<DreamWeaverHealth> {
|
||||
let status = 'offline';
|
||||
let detail: string | undefined;
|
||||
let comfyuiOnline: boolean | undefined;
|
||||
let comfyuiUrl: string | undefined;
|
||||
let checkpointReady: boolean | undefined;
|
||||
let checkpointCount: number | undefined;
|
||||
let availableCheckpoints: string[] | undefined;
|
||||
let preferredCheckpoints: string[] | undefined;
|
||||
let healthOk = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(resolveDreamWeaverUrl(undefined, '/health'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as {
|
||||
status?: unknown;
|
||||
detail?: unknown;
|
||||
comfyui?: unknown;
|
||||
comfyui_url?: unknown;
|
||||
comfyuiUrl?: unknown;
|
||||
checkpoint_ready?: unknown;
|
||||
checkpoint_count?: unknown;
|
||||
available_checkpoints?: unknown;
|
||||
preferred_checkpoints?: unknown;
|
||||
} | null;
|
||||
status = typeof body?.status === 'string' ? body.status : response.ok ? 'ok' : `HTTP ${response.status}`;
|
||||
detail = typeof body?.detail === 'string' ? body.detail : undefined;
|
||||
comfyuiOnline = typeof body?.comfyui === 'boolean' ? body.comfyui : undefined;
|
||||
comfyuiUrl = typeof body?.comfyui_url === 'string'
|
||||
? body.comfyui_url
|
||||
: typeof body?.comfyuiUrl === 'string'
|
||||
? body.comfyuiUrl
|
||||
: undefined;
|
||||
checkpointReady = typeof body?.checkpoint_ready === 'boolean' ? body.checkpoint_ready : undefined;
|
||||
checkpointCount = typeof body?.checkpoint_count === 'number' ? body.checkpoint_count : undefined;
|
||||
availableCheckpoints = Array.isArray(body?.available_checkpoints)
|
||||
? body.available_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
preferredCheckpoints = Array.isArray(body?.preferred_checkpoints)
|
||||
? body.preferred_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
healthOk = response.ok && ['ok', 'healthy', 'online'].includes(status.toLowerCase());
|
||||
} catch (error) {
|
||||
detail = error instanceof Error ? error.message : 'Unable to reach Dream Weaver gateway.';
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/status/velocity-route-probe'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
if (probe.ok) {
|
||||
return { online: healthOk, routeMounted: true, status, comfyuiOnline, comfyuiUrl, checkpointReady, checkpointCount, availableCheckpoints, preferredCheckpoints, detail };
|
||||
}
|
||||
const probeMessage = await readErrorMessage(probe, '');
|
||||
const expectedMissingJob = probe.status === 404 && /job|not found|missing/i.test(probeMessage);
|
||||
return {
|
||||
online: healthOk && expectedMissingJob,
|
||||
routeMounted: expectedMissingJob,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: detail ?? probeMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
online: false,
|
||||
routeMounted: false,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: error instanceof Error ? error.message : detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDreamWeaverJob(input: SubmitDreamWeaverJobInput): Promise<DreamWeaverJobResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', input.image, input.image.name || 'room-source.jpg');
|
||||
formData.append('room_type', input.roomType);
|
||||
const trimmedKeywords = input.keywords.trim();
|
||||
if (trimmedKeywords.length > 0) {
|
||||
formData.append('keywords', trimmedKeywords);
|
||||
}
|
||||
|
||||
return requestDreamWeaverJson<DreamWeaverJobResponse>(resolveDreamWeaverUrl(undefined, '/dream-weaver'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDreamWeaverStatus(job: Pick<DreamWeaverJobResponse, 'job_id' | 'poll_url'>): Promise<DreamWeaverStatusResponse> {
|
||||
return requestDreamWeaverJson<DreamWeaverStatusResponse>(
|
||||
resolveDreamWeaverUrl(job.poll_url, `/dream-weaver/status/${encodeURIComponent(job.job_id)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDreamWeaverResult(jobId: string, resultUrl?: string): Promise<Blob> {
|
||||
const response = await fetch(resolveDreamWeaverUrl(resultUrl, `/dream-weaver/result/${encodeURIComponent(jobId)}`), {
|
||||
headers: buildDreamWeaverHeaders({ Accept: 'image/png,image/*,*/*' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver result failed: ${response.status}`));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
import {
|
||||
buildVelocityHeaders,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
export {
|
||||
VELOCITY_TOKEN_KEY,
|
||||
clearVelocityToken,
|
||||
getVelocityToken,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
|
||||
export interface VelocityUserProfile {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
@@ -13,6 +22,7 @@ export interface VelocityUserProfile {
|
||||
export interface VelocityActiveUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
@@ -148,18 +158,7 @@ export interface InventoryPropertySummary {
|
||||
}
|
||||
|
||||
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
const headers = new Headers(init);
|
||||
if (includeJson && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
const token = getVelocityToken();
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
return buildVelocityHeaders(init, includeJson);
|
||||
}
|
||||
|
||||
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -182,18 +181,6 @@ async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
return localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function normalizeVelocityRole(role: string | null | undefined): string {
|
||||
return (role ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
37
app/src/lib/velocitySession.ts
Normal file
37
app/src/lib/velocitySession.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function buildVelocityHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
const headers = new Headers(init);
|
||||
if (includeJson && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
const token = getVelocityToken();
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
OracleEnvelope,
|
||||
CanvasPageRevision,
|
||||
} from '../types/canvas';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocitySession';
|
||||
|
||||
function getBrowserOrigin(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MarketingState {
|
||||
adInsights: AdInsight[];
|
||||
liveEvents: LiveOptimizationEvent[];
|
||||
settings: CatalystSettings;
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
|
||||
|
||||
// Actions
|
||||
addCampaign: (campaign: Campaign) => void;
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface CrmOpportunityCard {
|
||||
probability: number | null;
|
||||
expected_close_date: string | null;
|
||||
next_action: string | null;
|
||||
notes?: string | null;
|
||||
project_id: string | null;
|
||||
unit_id: string | null;
|
||||
// When fetched from list endpoint, person-level fields are included
|
||||
@@ -109,6 +110,16 @@ export interface CrmTask {
|
||||
client_phone?: string;
|
||||
}
|
||||
|
||||
export interface CrmLeadStageUpdate {
|
||||
lead_id: string;
|
||||
person_id: string;
|
||||
status: CrmLeadStatus;
|
||||
budget_band: string | null;
|
||||
urgency: string | null;
|
||||
client_name?: string;
|
||||
client_phone?: string;
|
||||
}
|
||||
|
||||
// ── Property Interest ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PropertyInterest {
|
||||
|
||||
Reference in New Issue
Block a user