fix: complete Velocity-OS feature migration wiring

This commit is contained in:
2026-05-02 16:39:10 +05:30
parent 58628dac35
commit 8b2d836589
13 changed files with 779 additions and 381 deletions

View File

@@ -79,7 +79,7 @@ const router = createBrowserRouter([
// ── Admin-Only Control Room (RBAC gated at component + API level) ──
{
path: '/control-room',
path: '/control-room/:panel?',
element: (
<AuthGuard>
<AdminGuard>
@@ -87,14 +87,6 @@ const router = createBrowserRouter([
</AdminGuard>
</AuthGuard>
),
children: [
{ index: true, element: <Navigate to="/control-room/system" replace /> },
{ path: 'system', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'oracle-admin', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'comms-config', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'users', element: <Lazy><ControlRoom /></Lazy> },
{ path: 'models', element: <Lazy><ControlRoom /></Lazy> },
],
},
// ── Public vault links (no auth) ──

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import styles from './ControlRoom.module.css';
/**
@@ -39,7 +38,9 @@ const PANELS: { id: Panel; label: string; icon: string }[] = [
export default function ControlRoom() {
const navigate = useNavigate();
const [active, setActive] = useState<Panel>('system');
const location = useLocation();
const routePanel = location.pathname.split('/').filter(Boolean)[1] as Panel | undefined;
const active: Panel = PANELS.some((panel) => panel.id === routePanel) ? routePanel! : 'system';
return (
<div className={styles.root}>
@@ -64,7 +65,7 @@ export default function ControlRoom() {
<button
key={id}
className={`${styles.sideItem} ${active === id ? styles.sideActive : ''}`}
onClick={() => setActive(id)}
onClick={() => navigate(`/control-room/${id}`)}
aria-current={active === id ? 'page' : undefined}
>
<span className={styles.sideIcon}>{icon}</span>

View File

@@ -4,6 +4,7 @@ import { OracleResultCard } from './OracleResultCard';
import styles from './OracleBar.module.css';
type QueryState = 'idle' | 'thinking' | 'result';
type OracleEnvelope = { status?: string; data?: unknown; detail?: string; message?: string; error?: string };
/**
* OracleBar
@@ -47,20 +48,30 @@ export function OracleBar() {
const resp = await fetch('/api/oracle/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, context: 'command_pillar' }),
body: JSON.stringify({ prompt: query, context: { surface: 'command_pillar' } }),
});
const payload = await resp.json().catch(() => ({})) as OracleEnvelope;
if (!resp.ok) {
throw new Error(payload.detail || payload.message || payload.error || `HTTP ${resp.status}`);
}
setResult(payload.data ?? payload);
setQueryState('result');
} catch (error) {
setResult({
title: 'Oracle query failed',
summary: error instanceof Error ? error.message : 'Unable to run Oracle query.',
rows: [],
columns: [],
warnings: ['Check the Oracle API route and backend logs.'],
});
const data = await resp.json();
setResult(data);
setQueryState('result');
} catch {
setQueryState('idle');
}
}, [query, queryState]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
void handleSubmit();
}
if (e.key === 'Escape') {
setIsFocused(false);
@@ -123,7 +134,7 @@ export function OracleBar() {
{query && queryState !== 'thinking' && (
<motion.button
className={styles.submitBtn}
onClick={handleSubmit}
onClick={() => void handleSubmit()}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}

View File

@@ -1,12 +1,21 @@
/* OracleResultCard */
.card { display: flex; flex-direction: column; gap: var(--space-4); }
.card { display: flex; flex-direction: column; gap: var(--space-4); overflow: hidden; }
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.summary { font-size: var(--text-base); color: var(--color-text-primary); margin: 0; line-height: var(--leading-relaxed); }
.aiStar { color: var(--color-violet-light); margin-right: var(--space-2); }
.rowCount { font-size: var(--text-xs); color: var(--color-text-tertiary); white-space: nowrap; }
.list { display: flex; flex-direction: column; gap: var(--space-2); }
.listRow { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: var(--glass-border); }
.rowLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
.rowValue { font-size: var(--text-sm); font-weight: var(--font-semibold); }
.empty { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-6); border-radius: var(--radius-lg); background: rgba(255,255,255,0.03); color: var(--color-text-secondary); }
.empty small { color: var(--color-text-tertiary); }
.metric { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-4) 0; }
.metricValue { font-size: var(--text-5xl); font-weight: var(--font-bold); color: var(--color-text-primary); }
.metricLabel { font-size: var(--text-sm); color: var(--color-text-secondary); }
.tableWrap { overflow-x: auto; border-radius: var(--radius-lg); border: var(--glass-border); }
.table { width: 100%; border-collapse: collapse; min-width: 640px; }
.table th { text-align: left; padding: var(--space-3); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tracking-wider); color: var(--color-text-tertiary); background: rgba(255,255,255,0.03); }
.table td { padding: var(--space-3); border-top: var(--glass-border); font-size: var(--text-xs); color: var(--color-text-secondary); max-width: 220px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.actionRow { display: flex; gap: var(--space-3); flex-wrap: wrap; }
.provenance { align-self: center; font-size: 10px; color: var(--color-text-tertiary); font-family: var(--font-mono); }

View File

@@ -2,28 +2,17 @@ import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import styles from './OracleResultCard.module.css';
/**
* OracleResultCard
* Renders the Oracle response. Each data row stagger-fades at 100ms intervals.
* Always shows an AI summary line above the data.
* NEVER shows: canvas ID, fork/merge controls, schema catalog, MCP tools,
* revision history, query plan, execution stats, or raw SQL.
*/
type OracleRow = Record<string, unknown>;
interface OracleResult {
summary: string; // AI-generated plain-English summary
visualization?: {
type: 'bar' | 'list' | 'metric' | 'table';
data: any[];
xKey?: string;
yKey?: string;
columns?: string[];
};
actions?: {
label: string;
personId?: string;
type: 'view_client' | 'generic';
}[];
title?: string;
summary?: string;
columns?: string[];
rows?: OracleRow[];
rowCount?: number;
componentType?: 'bar' | 'list' | 'metric' | 'table' | string;
warnings?: string[];
sourceTables?: string[];
}
interface OracleResultCardProps {
@@ -31,9 +20,48 @@ interface OracleResultCardProps {
query: string;
}
function displayValue(value: unknown): string {
if (value == null || value === '') return '-';
if (typeof value === 'number') {
return Number.isInteger(value) ? value.toLocaleString('en-IN') : value.toLocaleString('en-IN', { maximumFractionDigits: 2 });
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (value instanceof Date) return value.toLocaleString();
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function inferLabel(row: OracleRow): string {
const preferred = ['name', 'full_name', 'client_name', 'project_name', 'title', 'label'];
const key = preferred.find((candidate) => row[candidate] != null);
if (key) return displayValue(row[key]);
const first = Object.values(row).find((value) => value != null);
return displayValue(first);
}
function inferValue(row: OracleRow): string {
const preferred = ['value', 'score', 'qd_score', 'count', 'total', 'amount', 'interest_count'];
const key = preferred.find((candidate) => row[candidate] != null);
if (key) return displayValue(row[key]);
const values = Object.values(row).filter((value) => value != null);
return displayValue(values[1] ?? values[0]);
}
function personIdFromRow(row: OracleRow): string | undefined {
const raw = row.person_id ?? row.client_id ?? row.lead_id ?? row.id;
return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
}
export function OracleResultCard({ result, query }: OracleResultCardProps) {
const navigate = useNavigate();
const rows = result.visualization?.data ?? [];
const rows = Array.isArray(result.rows) ? result.rows : [];
const columns = Array.isArray(result.columns) && result.columns.length > 0
? result.columns
: rows[0]
? Object.keys(rows[0])
: [];
const type = result.componentType ?? (columns.length > 4 ? 'table' : 'list');
const firstPersonId = rows.map(personIdFromRow).find(Boolean);
return (
<motion.div
@@ -42,71 +70,85 @@ export function OracleResultCard({ result, query }: OracleResultCardProps) {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
{/* AI summary line — always first */}
<motion.p
className={styles.summary}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
>
<span className={styles.aiStar}></span> {result.summary}
</motion.p>
<div className={styles.header}>
<p className={styles.summary}>
<span className={styles.aiStar}></span>
{result.summary || result.title || `Oracle result for "${query}"`}
</p>
{typeof result.rowCount === 'number' && (
<span className={styles.rowCount}>{result.rowCount.toLocaleString('en-IN')} rows</span>
)}
</div>
{/* Data rows stagger-fade */}
{result.visualization?.type === 'list' && (
<div className={styles.list}>
{rows.map((row: any, i: number) => (
<motion.div
key={i}
className={styles.listRow}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + i * 0.1, duration: 0.25 }}
>
<span className={styles.rowLabel}>{row.label ?? row.name}</span>
<span className={styles.rowValue}
style={{ color: 'var(--color-violet-light)' }}>
{row.value ?? row.score ?? row.count}
</span>
</motion.div>
))}
{rows.length === 0 && (
<div className={styles.empty}>
{result.title || 'No rows returned'}
{result.warnings?.length ? <small>{result.warnings[0]}</small> : null}
</div>
)}
{result.visualization?.type === 'metric' && rows[0] && (
{rows.length > 0 && type === 'metric' && (
<motion.div
className={styles.metric}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<span className={styles.metricValue}>{rows[0].value}</span>
<span className={styles.metricLabel}>{rows[0].label}</span>
<span className={styles.metricValue}>{inferValue(rows[0])}</span>
<span className={styles.metricLabel}>{inferLabel(rows[0])}</span>
</motion.div>
)}
{/* Contextual actions */}
{result.actions && result.actions.length > 0 && (
<motion.div
className={styles.actionRow}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 + rows.length * 0.1 }}
>
{result.actions.map((action, i) => (
<button
{rows.length > 0 && type !== 'metric' && columns.length <= 4 && (
<div className={styles.list}>
{rows.slice(0, 8).map((row, i) => (
<motion.div
key={i}
className="btn-ghost"
onClick={() => {
if (action.type === 'view_client' && action.personId) {
navigate(`/pipeline/${action.personId}`);
}
}}
className={styles.listRow}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + i * 0.08, duration: 0.25 }}
>
{action.label}
</button>
<span className={styles.rowLabel}>{inferLabel(row)}</span>
<span className={styles.rowValue}>{inferValue(row)}</span>
</motion.div>
))}
</motion.div>
</div>
)}
{rows.length > 0 && type !== 'metric' && columns.length > 4 && (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>{columns.slice(0, 6).map((column) => <th key={column}>{column.replace(/_/g, ' ')}</th>)}</tr>
</thead>
<tbody>
{rows.slice(0, 8).map((row, rowIndex) => (
<motion.tr
key={rowIndex}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 + rowIndex * 0.05, duration: 0.2 }}
>
{columns.slice(0, 6).map((column) => <td key={column}>{displayValue(row[column])}</td>)}
</motion.tr>
))}
</tbody>
</table>
</div>
)}
{(firstPersonId || result.sourceTables?.length) && (
<div className={styles.actionRow}>
{firstPersonId && (
<button className="btn-ghost" onClick={() => navigate(`/pipeline/${firstPersonId}`)}>
Open top client
</button>
)}
{result.sourceTables?.length ? (
<span className={styles.provenance}>{result.sourceTables.slice(0, 4).join(', ')}</span>
) : null}
</div>
)}
</motion.div>
);

View File

@@ -1,22 +1,237 @@
/* ReimaginePanel */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.reimagineBtn { align-self: flex-start; }
.presetsSection { display: flex; flex-direction: column; gap: var(--space-4); }
.presetGrid { display: flex; gap: var(--space-3); }
.presetTile { position: relative; display: flex; flex-direction: column; align-items: center; gap: var(--space-2); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-3); cursor: pointer; transition: all var(--duration-fast) var(--ease-standard); width: 120px; flex-shrink: 0; }
.presetTile:hover { background: var(--glass-bg-hover); }
.selected { border-color: var(--color-violet) !important; }
.previewImg { width: 100%; height: 72px; border-radius: var(--radius-md); overflow: hidden; background: var(--glass-bg); }
.previewImg img { width: 100%; height: 100%; object-fit: cover; }
.presetLabel { font-size: 10px; font-weight: var(--font-medium); color: var(--color-text-secondary); text-align: center; }
.selectionRing { position: absolute; inset: -2px; border-radius: var(--radius-lg); border: 2px solid var(--color-violet); pointer-events: none; box-shadow: var(--glass-shadow-violet); }
.generateBtn { align-self: flex-start; }
/* Generating */
.generatingState { display: flex; flex-direction: column; gap: var(--space-4); }
.imageFrame { height: 200px; border-radius: var(--radius-xl); }
.generatingText { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
/* Result */
.resultState { display: flex; flex-direction: column; gap: var(--space-4); }
.resultImage { border-radius: var(--radius-xl); overflow: hidden; }
.resultImage img { width: 100%; height: 200px; object-fit: cover; }
.resultActions { display: flex; gap: var(--space-3); }
.root {
display: grid;
grid-template-columns: minmax(320px, 0.92fr) minmax(360px, 1.08fr);
gap: var(--space-5);
}
.sourcePanel,
.resultPanel {
border: var(--glass-border);
border-radius: var(--radius-xl);
background:
radial-gradient(circle at 20% 0%, rgba(124, 58, 237, 0.16), transparent 38%),
rgba(255, 255, 255, 0.04);
box-shadow: var(--glass-shadow);
}
.sourcePanel {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
}
.resultPanel {
min-height: 520px;
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
}
.panelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.panelHeader h3 {
margin: var(--space-1) 0 0;
font-size: var(--text-xl);
color: var(--color-text-primary);
}
.eyebrow {
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-violet-light);
font-weight: var(--font-semibold);
}
.healthOk,
.healthWarn {
max-width: 220px;
font-size: 10px;
line-height: 1.4;
text-align: right;
}
.healthOk { color: var(--color-green); }
.healthWarn { color: var(--color-amber); }
.uploadFrame {
width: 100%;
aspect-ratio: 16 / 10;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.03);
color: var(--color-text-secondary);
overflow: hidden;
cursor: pointer;
}
.uploadFrame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.uploadEmpty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.fileInput { display: none; }
.roomGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
.roomButton,
.roomActive {
border: var(--glass-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.035);
color: var(--color-text-secondary);
padding: var(--space-3);
font-size: var(--text-sm);
cursor: pointer;
}
.roomActive {
color: var(--color-text-primary);
border-color: rgba(124, 58, 237, 0.5);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14);
}
.promptLabel {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-text-tertiary);
font-weight: var(--font-semibold);
}
.promptLabel textarea {
min-height: 108px;
resize: vertical;
border: var(--glass-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.04);
color: var(--color-text-primary);
padding: var(--space-3);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.6;
outline: none;
text-transform: none;
letter-spacing: 0;
font-weight: var(--font-normal);
}
.promptLabel textarea:focus {
border-color: rgba(124, 58, 237, 0.44);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14);
}
.generateButton {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: none;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: white;
padding: var(--space-3) var(--space-5);
font-weight: var(--font-semibold);
cursor: pointer;
}
.generateButton:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.resultState,
.errorState {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
color: var(--color-text-secondary);
text-align: center;
padding: var(--space-8);
}
.resultState strong,
.errorState strong {
color: var(--color-text-primary);
font-size: var(--text-lg);
}
.resultState span,
.errorState span {
max-width: 420px;
font-size: var(--text-sm);
}
.errorState {
background: rgba(127, 29, 29, 0.14);
color: var(--color-red);
}
.resultReady {
width: 100%;
display: flex;
flex-direction: column;
}
.resultReady img {
width: 100%;
min-height: 420px;
flex: 1;
object-fit: contain;
background: rgba(0, 0, 0, 0.28);
}
.resultActions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-4);
border-top: var(--glass-border);
background: rgba(0, 0, 0, 0.18);
}
.resultActions button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1100px) {
.root { grid-template-columns: 1fr; }
.resultPanel { min-height: 380px; }
}

View File

@@ -1,236 +1,229 @@
import { useState, useCallback } from 'react';
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Download, ExternalLink, ImagePlus, Loader2, Sparkles, UploadCloud } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
checkDreamWeaverHealth,
fetchDreamWeaverResult,
getDreamWeaverStatus,
submitDreamWeaverJob,
type DreamWeaverHealth,
type DreamWeaverJobResponse,
} from '@/shared/lib/dreamWeaverApi';
import styles from './ReimaginePanel.module.css';
/**
* ReimaginePanel
* One-tap AI room staging. Replaces the raw Dream Weaver tab.
* UX Master Plan §4.4 + Part 2 §2.4 spec:
*
* - Tap "Reimagine" → 3 style tiles stagger-reveal (50ms intervals)
* - Tiles show AI-representative preview images, not labels
* - Select style → "Generate" materializes with spring
* - Generation → property view blurs + shimmer sweeps
* - Result → cross-fade, two actions: Use This / Try Another
*
* All ComfyUI machinery hidden. No job IDs, no queue position.
* User sees: "Reimagining your space…"
*/
type StylePreset = 'modern-luxury' | 'warm-contemporary' | 'minimalist-zen';
type Phase = 'idle' | 'selecting' | 'generating' | 'result';
type RoomType = 'bedroom' | 'living_room' | 'bathroom' | 'kitchen' | 'dining_room' | 'office';
type Phase = 'idle' | 'ready' | 'generating' | 'result' | 'error';
interface ReimagineResult {
imageUrl: string;
jobId: string; // internal only, never shown
url: string;
jobId: string;
blob: Blob;
}
interface ReimaginePanelProps {
propertyId: string;
roomImageUrl?: string; // Source room photo
roomImageUrl?: string;
onResultSaved?: (url: string) => void;
}
const STYLE_PRESETS: { id: StylePreset; label: string; previewUrl: string }[] = [
{
id: 'modern-luxury',
label: 'Modern Luxury',
// Curated representative preview images
previewUrl: '/assets/style-previews/modern-luxury.jpg',
},
{
id: 'warm-contemporary',
label: 'Warm Contemporary',
previewUrl: '/assets/style-previews/warm-contemporary.jpg',
},
{
id: 'minimalist-zen',
label: 'Minimalist Zen',
previewUrl: '/assets/style-previews/minimalist-zen.jpg',
},
const ROOM_TYPES: { id: RoomType; label: string }[] = [
{ 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: 'office', label: 'Office' },
];
export function ReimaginePanel({
propertyId,
roomImageUrl,
onResultSaved,
}: ReimaginePanelProps) {
const DEFAULT_PROMPT = 'Modern luxury staging, warm practical lighting, premium materials, uncluttered real estate brochure finish';
export function ReimaginePanel({ propertyId, roomImageUrl, onResultSaved }: ReimaginePanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [phase, setPhase] = useState<Phase>('idle');
const [selectedStyle, setSelectedStyle] = useState<StylePreset | null>(null);
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
const [roomType, setRoomType] = useState<RoomType>('bedroom');
const [keywords, setKeywords] = useState(DEFAULT_PROMPT);
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState<string | null>(roomImageUrl ?? null);
const [job, setJob] = useState<DreamWeaverJobResponse | null>(null);
const [result, setResult] = useState<ReimagineResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [pollCount, setPollCount] = useState(0);
const handleReimagineClick = () => setPhase('selecting');
useEffect(() => {
let alive = true;
checkDreamWeaverHealth()
.then((value) => {
if (!alive) return;
setHealth(value);
setPhase('ready');
})
.catch((err) => {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Dream Weaver health check failed.');
setPhase('error');
});
return () => { alive = false; };
}, []);
const handleStyleSelect = (style: StylePreset) => {
setSelectedStyle(style);
useEffect(() => {
return () => {
if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview);
if (result?.url.startsWith('blob:')) URL.revokeObjectURL(result.url);
};
}, [sourcePreview, result]);
const canGenerate = Boolean(sourceFile) && phase !== 'generating';
const healthLabel = useMemo(() => {
if (!health) return 'Checking Dream Weaver';
if (health.online && health.routeMounted) return 'Dream Weaver online';
return health.detail || health.status || 'Dream Weaver unavailable';
}, [health]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (sourcePreview?.startsWith('blob:')) URL.revokeObjectURL(sourcePreview);
setSourceFile(file);
setSourcePreview(URL.createObjectURL(file));
setResult(null);
setError(null);
setPhase('ready');
};
const handleGenerate = useCallback(async () => {
if (!selectedStyle) return;
if (!sourceFile) {
setError('Upload a room image before generating.');
setPhase('error');
return;
}
setPhase('generating');
setError(null);
setPollCount(0);
try {
// POST to Dream Weaver gateway (hidden from user)
const resp = await fetch('/dream-weaver', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
property_id: propertyId,
style_preset: selectedStyle,
source_image_url: roomImageUrl,
}),
});
const { job_id } = await resp.json();
const submitted = await submitDreamWeaverJob({ image: sourceFile, roomType, keywords });
setJob(submitted);
// Poll silently (user sees shimmer, not polling)
const imageUrl = await pollForResult(job_id);
setResult({ imageUrl, jobId: job_id });
setPhase('result');
} catch {
setPhase('selecting');
for (let attempt = 1; attempt <= 180; attempt += 1) {
setPollCount(attempt);
const status = await getDreamWeaverStatus(submitted);
if (status.status === 'failed') {
throw new Error(status.error || 'Dream Weaver generation failed.');
}
if (status.ready || status.status === 'complete') {
const blob = await fetchDreamWeaverResult(submitted.job_id, status.result_url ?? submitted.result_url);
const url = URL.createObjectURL(blob);
setResult({ url, jobId: submitted.job_id, blob });
setPhase('result');
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 2000));
}
throw new Error('Dream Weaver timed out before returning an image.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Dream Weaver request failed.');
setPhase('error');
}
}, [selectedStyle, propertyId, roomImageUrl]);
}, [keywords, roomType, sourceFile]);
const handleUseThis = async () => {
const handleDownload = () => {
if (!result) return;
// Save to property media library + generate vault link (handled by Studio)
onResultSaved?.(result.imageUrl);
setPhase('idle');
setResult(null);
setSelectedStyle(null);
};
const handleTryAnother = () => {
setPhase('selecting');
setResult(null);
setSelectedStyle(null);
const anchor = document.createElement('a');
anchor.href = result.url;
anchor.download = `${propertyId}-reimagine-${result.jobId}.png`;
anchor.click();
};
return (
<div className={styles.root}>
{/* ── Idle: single button ─────────────────────────── */}
{phase === 'idle' && (
<motion.button
className={`btn-primary ${styles.reimagineBtn}`}
onClick={handleReimagineClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Reimagine
</motion.button>
)}
{/* ── Selecting: staggered style tiles ─────────────── */}
{(phase === 'selecting' || phase === 'result') && (
<div className={styles.presetsSection}>
<div className={styles.presetGrid}>
{STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
<motion.button
key={id}
className={`${styles.presetTile} ${selectedStyle === id ? styles.selected : ''}`}
onClick={() => handleStyleSelect(id)}
initial={{ opacity: 0, scale: 0.92 }}
animate={{
opacity: selectedStyle && selectedStyle !== id ? 0.4 : 1,
scale: selectedStyle === id ? 1.04 : 1,
}}
transition={{
delay: idx * 0.05,
duration: 0.25,
ease: [0.4, 0, 0.2, 1],
}}
aria-pressed={selectedStyle === id}
aria-label={`Style: ${label}`}
>
<div className={styles.previewImg}>
<img src={previewUrl} alt={label} />
</div>
<span className={styles.presetLabel}>{label}</span>
{/* Active glow border */}
{selectedStyle === id && (
<motion.div
layoutId="preset-selection"
className={styles.selectionRing}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</motion.button>
))}
<section className={styles.sourcePanel}>
<div className={styles.panelHeader}>
<div>
<span className={styles.eyebrow}>Source Room</span>
<h3>Reimagine this space</h3>
</div>
{/* Generate button materializes when style selected */}
<AnimatePresence>
{selectedStyle && phase !== 'result' && (
<motion.button
className={`btn-primary ${styles.generateBtn}`}
onClick={handleGenerate}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 25 }}
>
Generate
</motion.button>
)}
</AnimatePresence>
<span className={health?.online ? styles.healthOk : styles.healthWarn}>{healthLabel}</span>
</div>
)}
{/* ── Generating: shimmer overlay ──────────────────── */}
{phase === 'generating' && (
<motion.div
className={styles.generatingState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className={`${styles.imageFrame} shimmer`} />
<motion.p
className={styles.generatingText}
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{ duration: 2, repeat: Infinity }}
>
Reimagining your space
</motion.p>
</motion.div>
)}
<button className={styles.uploadFrame} onClick={() => fileInputRef.current?.click()} type="button">
{sourcePreview ? <img src={sourcePreview} alt="Selected room" /> : (
<span className={styles.uploadEmpty}>
<UploadCloud size={28} />
Upload room image
</span>
)}
</button>
<input ref={fileInputRef} className={styles.fileInput} type="file" accept="image/*" onChange={handleFileChange} />
{/* ── Result: cross-fade to generated image ─────────── */}
{phase === 'result' && result && (
<motion.div
className={styles.resultState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
<div className={styles.resultImage}>
<img src={result.imageUrl} alt="AI-generated staging" />
</div>
<div className={styles.resultActions}>
<button className="btn-primary" onClick={handleUseThis}>
Use This
<div className={styles.roomGrid}>
{ROOM_TYPES.map((room) => (
<button
key={room.id}
type="button"
className={roomType === room.id ? styles.roomActive : styles.roomButton}
onClick={() => setRoomType(room.id)}
>
{room.label}
</button>
<button className="btn-ghost" onClick={handleTryAnother}>
Try Another Style
</button>
</div>
</motion.div>
)}
))}
</div>
<label className={styles.promptLabel}>
Styling prompt
<textarea value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</label>
<button className={styles.generateButton} onClick={() => void handleGenerate()} disabled={!canGenerate}>
{phase === 'generating' ? <Loader2 size={18} className={styles.spin} /> : <Sparkles size={18} />}
Generate
</button>
</section>
<section className={styles.resultPanel}>
<AnimatePresence mode="wait">
{phase === 'generating' && (
<motion.div key="generating" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Loader2 className={styles.spin} size={34} />
<strong>Reimagining your space</strong>
<span>Render processing - poll {pollCount}/180</span>
</motion.div>
)}
{phase === 'result' && result && (
<motion.div key="result" className={styles.resultReady} initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}>
<img src={result.url} alt="Generated staging" />
<div className={styles.resultActions}>
<button className="btn-primary" onClick={() => onResultSaved?.(result.url)}>
<ImagePlus size={16} /> Use This
</button>
<button className="btn-ghost" onClick={handleDownload}>
<Download size={16} /> Download
</button>
<button className="btn-ghost" onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}>
<ExternalLink size={16} /> Open
</button>
</div>
</motion.div>
)}
{(phase === 'ready' || phase === 'idle') && (
<motion.div key="empty" className={styles.resultState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Sparkles size={34} />
<strong>No generated image yet</strong>
<span>Upload an image, tune the prompt, and generate a staged render.</span>
</motion.div>
)}
{phase === 'error' && (
<motion.div key="error" className={styles.errorState} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<strong>Dream Weaver unavailable</strong>
<span>{error}</span>
<button className="btn-ghost" onClick={() => setPhase(sourceFile ? 'ready' : 'idle')}>Dismiss</button>
</motion.div>
)}
</AnimatePresence>
</section>
</div>
);
}
// ── Internal: silent polling (hidden from user) ───────────────
async function pollForResult(jobId: string): Promise<string> {
const MAX_ATTEMPTS = 60;
const INTERVAL_MS = 5000;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
await new Promise(r => setTimeout(r, INTERVAL_MS));
const resp = await fetch(`/dream-weaver/status/${jobId}`);
const { status, result_url } = await resp.json();
if (status === 'complete' && result_url) return result_url;
if (status === 'failed') throw new Error('Generation failed');
}
throw new Error('Generation timed out');
}

View File

@@ -107,7 +107,10 @@ function CampaignsSection() {
queryKey: ['studio-campaigns'],
queryFn: async () => {
const response = await api.get<unknown>('/catalyst/campaigns?limit=100&offset=0');
return unwrapArray<CampaignSummary>(response);
return unwrapArray<CampaignSummary>(response, ['campaigns']).map((campaign, index) => ({
...campaign,
id: String((campaign as CampaignSummary & { campaign_id?: string }).id ?? (campaign as CampaignSummary & { campaign_id?: string }).campaign_id ?? index),
}));
},
staleTime: 60_000,
refetchOnMount: 'always',

View File

@@ -81,6 +81,17 @@ interface InventoryPropertyRecord {
availableUnits?: number;
}
interface InventoryMediaRecord {
url?: string;
thumbnail_url?: string;
thumbnailUrl?: string;
media_type?: string;
type?: string;
kind?: string;
label?: string;
metadata?: Record<string, unknown>;
}
function mapLocation(location: InventoryPropertyRecord['location']): string {
if (!location) return 'Location pending';
return [location.district, location.area, location.city, location.address].filter(Boolean).join(', ') || 'Location pending';
@@ -101,9 +112,9 @@ function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): stri
}
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
const media = stableArray<{ url?: string; thumbnail_url?: string }>(record.media);
const media = normalizeMedia(record);
const unitMix = stableArray<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>(record.unit_mix);
const mediaThumb = media.find((item) => item.thumbnail_url || item.url);
const mediaThumb = media.find((item) => item.thumbnail_url || item.thumbnailUrl || item.url);
const availableUnits = unitMix.length
? unitMix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
: record.availableUnits;
@@ -113,7 +124,7 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
name: record.project_name ?? record.name ?? 'Unnamed property',
location: mapLocation(record.location),
priceRange: mapPriceRange(record.price_bands),
thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.url,
thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.thumbnailUrl ?? mediaThumb?.url,
availableUnits,
};
}
@@ -121,18 +132,62 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
const base = mapInventoryProperty(record);
const unitMix = stableArray<{ configuration?: string; area?: string; price?: string }>(record.unit_mix);
const media = stableArray<{ url?: string }>(record.media);
const media = normalizeMedia(record);
const primaryUnit = unitMix[0];
const images = stableArray<string>(record.images).length
? stableArray<string>(record.images)
: media.map((item) => item.url).filter((url): url is string => Boolean(url));
const imageUrls = stableArray<string>(record.images);
const mediaImages = media
.filter((item) => isImageMedia(item))
.map((item) => item.url ?? item.thumbnail_url ?? item.thumbnailUrl)
.filter((url): url is string => Boolean(url));
const images = imageUrls.length ? imageUrls : mediaImages;
const modelUrl = media.find((item) => isModelMedia(item))?.url;
const interiorImageUrl = findInteriorImage(media) ?? images[0] ?? base.thumbnailUrl;
return {
...base,
config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration',
area: primaryUnit?.area ?? 'Area pending',
price: primaryUnit?.price ?? base.priceRange ?? 'Price pending',
description: `${record.developer_name ?? 'Developer'} property in ${base.location}`,
interiorImageUrl,
modelUrl,
images,
amenities: Array.isArray(record.amenities) ? record.amenities : [],
};
}
function normalizeMedia(record: InventoryPropertyRecord): InventoryMediaRecord[] {
const media = stableArray<InventoryMediaRecord>(record.media);
const imageRecords = stableArray<InventoryMediaRecord>(record.images);
const imageStrings: InventoryMediaRecord[] = stableArray<string>(record.images).map((url) => ({
url,
media_type: 'image',
}));
return [...media, ...imageRecords, ...imageStrings].filter((item) => Boolean(item.url ?? item.thumbnail_url ?? item.thumbnailUrl));
}
function mediaKind(item: InventoryMediaRecord): string {
return [item.media_type, item.type, item.kind, item.label]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
function isImageMedia(item: InventoryMediaRecord): boolean {
const url = (item.url ?? item.thumbnail_url ?? item.thumbnailUrl ?? '').toLowerCase();
const kind = mediaKind(item);
return kind.includes('image') || kind.includes('photo') || /\.(png|jpe?g|webp|gif|avif)(\?|$)/.test(url);
}
function isModelMedia(item: InventoryMediaRecord): boolean {
const url = (item.url ?? '').toLowerCase();
const kind = mediaKind(item);
return kind.includes('model') || kind.includes('3d') || kind.includes('vr') || /\.(glb|gltf)(\?|$)/.test(url);
}
function findInteriorImage(media: InventoryMediaRecord[]): string | undefined {
const item = media.find((entry) => {
const kind = mediaKind(entry);
return isImageMedia(entry) && (kind.includes('interior') || kind.includes('room') || kind.includes('staging'));
});
return item?.url ?? item?.thumbnail_url ?? item?.thumbnailUrl;
}

View File

@@ -1,4 +1,4 @@
import { buildVelocityHeaders } from '@/lib/velocitySession';
import { buildVelocityHeaders } from '@/shared/lib/velocitySession';
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';

View File

@@ -1,5 +1,5 @@
import { API_URL } from '@/lib/api';
import { buildVelocityHeaders } from '@/lib/velocitySession';
import { API_URL } from '@/shared/lib/api';
import { buildVelocityHeaders } from '@/shared/lib/velocitySession';
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();