fix: complete Velocity-OS feature migration wiring
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled

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

176
README.md
View File

@@ -1,83 +1,149 @@
# Velocity-OS
> **The AI-Augmented Real Estate Operating System** — a production-grade, air-gapped, containerized appliance for luxury property sales teams.
Velocity-OS is the simplified production shell for Project Velocity: an AI-assisted operating system for real estate sales, client intelligence, property media, campaign work, and admin control.
Built on the Jobs/Ive mandate: *design is how it works, not just how it looks.*
The goal is not to copy the old Project_Velocity UI screen-for-screen. The goal is to preserve the useful capabilities, reduce the surface area, and make the system usable through three clear work modes.
---
## Architecture At A Glance
## Architecture at a Glance
```
```text
Velocity-OS/
├── webos/ React 19 + Framer Motion + Zustand — 3-Pillar UX
├── core/ FastAPI + PostgreSQL + Oracle NL Engine
├── agents/ NemoClaw sandboxes (OpenShell policy)
├── media-engine/ Dream Weaver gateway (ComfyUI async)
├── perception/ MediaPipe WASM + QD scoring
└── infrastructure/ K3s + ECR + MIG GPU + s5cmd model hydration
|-- webos/ React 19, React Router, Framer Motion, Zustand, TanStack Query
|-- core/ FastAPI, PostgreSQL access, CRM APIs, Oracle natural DB agent
|-- media-engine/ Dream Weaver gateway for image generation / virtual staging
|-- agents/ Agent/runtime integration surface
|-- infrastructure/ Deployment, model hydration, gateway, and runtime operations
```
## The 3-Pillar Model
## The Three Pillars
| Pillar | Route | Purpose |
|---|---|---|
| **Command** | `/command` | Morning briefing — KPIs, Oracle NL, AI priority cards |
| 🎯 **Pipeline** | `/pipeline` | Deal intelligence — Kanban, Client 360, Showroom |
| 🎨 **Studio** | `/studio` | Asset hub — Properties, Dream Weaver, Campaigns |
| Control Room | `/control-room` | Admin-only — system, schema, users, GPU |
| Pillar | Route | What It Replaces From Project_Velocity |
| --- | --- | --- |
| Command | `/command` | Dashboard, Oracle prompt surface, priority signals |
| Pipeline | `/pipeline` and `/pipeline/:personId` | CRM, leads, Client 360, conversations, tasks, showroom flow |
| Studio | `/studio` and `/studio/:propertyId` | Inventory, property pages, media gallery, Dream Weaver / Reimagine, campaigns |
| Control Room | `/control-room/:panel?` | Admin settings, Oracle/schema controls, comms settings, users, model hydration |
## GPU Architecture
## Runtime Truths
Target: **NVIDIA RTX 6000 Blackwell (96GB VRAM)**
- The public app is served from `https://velocity.desineuron.in/`.
- Frontend API calls go through the same origin unless `VITE_API_URL` is set.
- Dream Weaver uses multipart image upload through `/dream-weaver`, then polls `/dream-weaver/status/{job_id}`, then downloads from `/dream-weaver/result/{job_id}`.
- The deployed GPU reality is the Desineuron AWS GPU worker, currently oriented around `4 x NVIDIA L4` for inference/runtime work. Do not assume the placeholder RTX 6000/MIG text from older mock screens is operational truth.
- Large model and media runtime assets belong on GPU NVMe, not Linux root disk.
MIG partitioned into two concurrent slices:
- **Slice 0 (48GB)** — SGLang: Qwen3.6 35B LLM inference
- **Slice 1 (48GB)** — ComfyUI: Wan 2.2 + Qwen-Image staging
## Oracle Canvas And JSON Schemas
Zero-contention. No operator toggle required.
Velocity-OS imports the Project_Velocity Oracle JSON codebook assets required for canvas/component planning:
## Deployment (Air-Gapped Workstation)
- `core/oracle/oracle/oracle_runtime_codebook_merged.json`
- `core/oracle/oracle/oracle_template_seed_db.json`
```bash
# 1. Hydrate AI models from S3 (one-time, ~105GB)
cd infrastructure/model-hydration
./hydrate_models.sh
The loader lives at:
# 2. Apply K3s manifests
kubectl apply -f infrastructure/k3s/namespaces/
kubectl apply -f infrastructure/k3s/volumes/
kubectl apply -f infrastructure/k3s/secrets/secrets-template.yaml # fill real values first
kubectl apply -f infrastructure/k3s/deployments/gpu-mig-config.yaml
kubectl apply -f infrastructure/k3s/deployments/deployments.yaml
kubectl apply -f infrastructure/k3s/services/services.yaml
kubectl apply -f infrastructure/k3s/ingress/ingress.yaml
- `core/oracle/oracle/codebook_service.py`
# 3. Run DB init job
kubectl wait --for=condition=complete job/db-init -n velocity-os --timeout=120s
The loader now resolves the native Velocity-OS paths first, with legacy `backend/oracle` paths only as compatibility inputs. A quick sanity check should report the large runtime codebook:
# 4. Access at https://velocity.local
```powershell
python -c "from core.oracle.oracle.codebook_service import codebook_service; print(codebook_service.stats())"
```
## CI/CD Pipeline
Expected current result:
Internet-connected CI (GitLab) builds and cosign-signs images → pushes to AWS ECR.
Ingress Box (LAN node) polls ECR every 5 min → verifies signature → SCP to air-gapped workstation → `k3s ctr images import``kubectl rollout restart`.
```text
example_count: 2395
template_count: 1320
source_summary includes runtime_merged:2395
```
**Unsigned images are physically rejected at the Ingress Box.**
Oracle natural query flow:
## Design System
1. The user asks a question in Command.
2. `webos/src/pillars/command/OracleBar.tsx` posts `{ prompt, context }` to `/api/oracle/query`.
3. `core/api/api/routes_oracle.py` calls the Oracle natural DB agent.
4. The result returns SQL-backed rows, columns, warnings, and source tables.
5. `OracleResultCard` renders the result as a metric, list, or table based on the returned data shape.
- **Base**: `hsl(225, 25%, 8%)` deep navy
- **Accent**: `#7C3AED` Velocity Violet (AI/actions)
- **Intent**: `#F59E0B` Amber (QD / alerts)
- **Glass**: `backdrop-filter: blur(20px)` · `rgba(255,255,255,0.05)` panels
- **Motion**: `300ms cubic-bezier(0.4, 0, 0.2, 1)` standard · spring for reveals
## Studio And Reimagine
## Immutability Constraint
Studio uses:
`Project_Velocity` (source) is **read-only**. All files in this repository are copies + refactors. No source files were modified or deleted.
- `webos/src/shared/hooks/useStudio.ts` for inventory/property normalization.
- `webos/src/pillars/studio/StudioPillar.tsx` for property and campaign lists.
- `webos/src/pillars/studio/PropertyEntity.tsx` for the property detail page.
- `webos/src/pillars/studio/ReimaginePanel.tsx` for Dream Weaver.
---
Reimagine currently supports:
- source room image upload
- room type selection
- freeform styling prompt
- async Dream Weaver job submission
- polling without blocking the page
- generated image preview
- generated image download/open actions
## Control Room
Control Room is admin-only and route-driven:
- `/control-room/system`
- `/control-room/oracle-admin`
- `/control-room/comms-config`
- `/control-room/users`
- `/control-room/models`
- `/control-room/meta`
The selected panel is derived from the URL. This avoids stale local state when users navigate directly or return from another page.
## Local Development
Frontend:
```powershell
cd webos
npm install
npm run dev
```
Production build check:
```powershell
cd webos
npm run type-check
npm run build
```
Backend:
```powershell
cd core
python -m uvicorn api.main:app --host 127.0.0.1 --port 8001 --reload
```
Important environment variables:
- `VITE_API_URL` overrides the frontend API origin.
- `VITE_DREAM_WEAVER_URL` overrides Dream Weaver gateway origin.
- `VITE_DREAM_WEAVER_API_KEY` adds the optional Dream Weaver gateway key.
- Backend database settings are provided by the running deployment environment.
## Verification Checklist
Run before handoff:
- `npm run type-check` from `webos`
- `npm run build` from `webos`
- Oracle codebook stats check from repo root
- Login to `https://velocity.desineuron.in/`
- Open Command and submit an Oracle question
- Open Pipeline, switch Board/List, open a client, return to Pipeline without refresh
- Open Studio, switch Properties/Campaigns, open a property, use Reimagine upload + prompt
- Open Control Room and verify dark theme across all panels
## Known Engineering Notes
- The production build still warns that the Three.js vendor chunk is large. This is not a functional failure, but Studio 3D/media should remain lazy-loaded and can be split further later.
- The app intentionally keeps Project_Velocity as a source/reference repository. Velocity-OS should import only the required code/data assets, not blindly mirror the old structure.
*Velocity-OS v2.0 · Desineuron · © 2026*

View File

@@ -50,7 +50,11 @@ class CodebookExample:
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "webos").exists() and (parent / "core").exists():
return parent
return current.parents[3]
def _safe_load_json(path: Path) -> dict[str, Any]:
@@ -140,26 +144,33 @@ def _normalize_examples(payload: dict[str, Any], source_pack: str) -> list[Codeb
class OracleCodebookService:
def __init__(self) -> None:
root = _repo_root()
self.runtime_merged_path = root / "backend" / "oracle" / "oracle_runtime_codebook_merged.json"
self.primary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "GPT 5.4" / "oracle_canvas_json_expansion_pack" / "db" / "oracle_template_seed_db_expanded_v1.pretty.json"
self.secondary_path = root / ".Agent Context" / "Sprint 1" / "Sayan Multi-Surface and Oracle Delivery Pack" / "Sample JSON Schema" / "Claude Sonnet 4.6" / "oracle_template_expansion" / "oracle_template_seed_db_expanded.json"
self.fallback_path = root / "backend" / "oracle" / "oracle_template_seed_db.json"
oracle_dir = root / "core" / "oracle" / "oracle"
legacy_oracle_dir = root / "backend" / "oracle"
self.runtime_merged_path = oracle_dir / "oracle_runtime_codebook_merged.json"
self.primary_path = oracle_dir / "oracle_template_seed_db_expanded_v1.pretty.json"
self.secondary_path = oracle_dir / "oracle_template_seed_db_expanded_claude.json"
self.fallback_path = oracle_dir / "oracle_template_seed_db.json"
self.legacy_runtime_merged_path = legacy_oracle_dir / "oracle_runtime_codebook_merged.json"
self.legacy_fallback_path = legacy_oracle_dir / "oracle_template_seed_db.json"
@lru_cache(maxsize=1)
def load(self) -> dict[str, Any]:
corpora: list[CodebookExample] = []
sources_loaded: list[str] = []
source_paths: list[tuple[Path, str]]
if self.runtime_merged_path.exists():
if self.runtime_merged_path.exists() or self.legacy_runtime_merged_path.exists():
source_paths = [
(self.runtime_merged_path, "runtime_merged"),
(self.legacy_runtime_merged_path, "legacy_runtime_merged"),
(self.fallback_path, "runtime_seed_fallback"),
(self.legacy_fallback_path, "legacy_seed_fallback"),
]
else:
source_paths = [
(self.primary_path, "gpt_5_4"),
(self.secondary_path, "claude_sonnet_4_6"),
(self.fallback_path, "runtime_seed_fallback"),
(self.legacy_fallback_path, "legacy_seed_fallback"),
]
for path, label in source_paths:

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;
title?: string;
summary?: string;
columns?: string[];
};
actions?: {
label: string;
personId?: string;
type: 'view_client' | 'generic';
}[];
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 && (
{rows.length > 0 && type !== 'metric' && columns.length <= 4 && (
<div className={styles.list}>
{rows.slice(0, 8).map((row, i) => (
<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
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>
))}
</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.');
}
}, [selectedStyle, propertyId, roomImageUrl]);
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');
}
}, [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} />
<section className={styles.sourcePanel}>
<div className={styles.panelHeader}>
<div>
<span className={styles.eyebrow}>Source Room</span>
<h3>Reimagine this space</h3>
</div>
<span className={health?.online ? styles.healthOk : styles.healthWarn}>{healthLabel}</span>
</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 }}
/>
<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>
)}
</motion.button>
</button>
<input ref={fileInputRef} className={styles.fileInput} type="file" accept="image/*" onChange={handleFileChange} />
<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>
))}
</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 }}
>
<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
</motion.button>
</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>
</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>
)}
{/* ── 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
</button>
<button className="btn-ghost" onClick={handleTryAnother}>
Try Another Style
</button>
</div>
</motion.div>
)}
</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();