- {rows.map((row: any, i: number) => (
-
- {row.label ?? row.name}
-
- {row.value ?? row.score ?? row.count}
-
-
- ))}
+ {rows.length === 0 && (
+
+ {result.title || 'No rows returned'}
+ {result.warnings?.length ? {result.warnings[0]} : null}
)}
- {result.visualization?.type === 'metric' && rows[0] && (
+ {rows.length > 0 && type === 'metric' && (
- {rows[0].value}
- {rows[0].label}
+ {inferValue(rows[0])}
+ {inferLabel(rows[0])}
)}
- {/* Contextual actions */}
- {result.actions && result.actions.length > 0 && (
-
- {result.actions.map((action, i) => (
-
);
diff --git a/webos/src/pillars/studio/ReimaginePanel.module.css b/webos/src/pillars/studio/ReimaginePanel.module.css
index 738aaf6..d3dafe7 100644
--- a/webos/src/pillars/studio/ReimaginePanel.module.css
+++ b/webos/src/pillars/studio/ReimaginePanel.module.css
@@ -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; }
+}
diff --git a/webos/src/pillars/studio/ReimaginePanel.tsx b/webos/src/pillars/studio/ReimaginePanel.tsx
index 377bee5..ccbe855 100644
--- a/webos/src/pillars/studio/ReimaginePanel.tsx
+++ b/webos/src/pillars/studio/ReimaginePanel.tsx
@@ -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
(null);
const [phase, setPhase] = useState('idle');
- const [selectedStyle, setSelectedStyle] = useState(null);
+ const [health, setHealth] = useState(null);
+ const [roomType, setRoomType] = useState('bedroom');
+ const [keywords, setKeywords] = useState(DEFAULT_PROMPT);
+ const [sourceFile, setSourceFile] = useState(null);
+ const [sourcePreview, setSourcePreview] = useState(roomImageUrl ?? null);
+ const [job, setJob] = useState(null);
const [result, setResult] = useState(null);
+ const [error, setError] = useState(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) => {
+ 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 (
- {/* ── Idle: single button ─────────────────────────── */}
- {phase === 'idle' && (
-
- ✨ Reimagine
-
- )}
-
- {/* ── Selecting: staggered style tiles ─────────────── */}
- {(phase === 'selecting' || phase === 'result') && (
-
-
- {STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
-
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}`}
- >
-
-

-
- {label}
-
- {/* Active glow border */}
- {selectedStyle === id && (
-
- )}
-
- ))}
+
+
+
+ Source Room
+
Reimagine this space
-
- {/* Generate button materializes when style selected */}
-
- {selectedStyle && phase !== 'result' && (
-
- Generate
-
- )}
-
+
{healthLabel}
- )}
- {/* ── Generating: shimmer overlay ──────────────────── */}
- {phase === 'generating' && (
-
-
-
- Reimagining your space…
-
-
- )}
+ fileInputRef.current?.click()} type="button">
+ {sourcePreview ?
: (
+
+
+ Upload room image
+
+ )}
+
+
- {/* ── Result: cross-fade to generated image ─────────── */}
- {phase === 'result' && result && (
-
-
-

-
-
-
- Use This
+
+ {ROOM_TYPES.map((room) => (
+ setRoomType(room.id)}
+ >
+ {room.label}
-
- Try Another Style
-
-
-
- )}
+ ))}
+
+
+
+
+ void handleGenerate()} disabled={!canGenerate}>
+ {phase === 'generating' ? : }
+ Generate
+
+
+
+
+
+ {phase === 'generating' && (
+
+
+ Reimagining your space
+ Render processing - poll {pollCount}/180
+
+ )}
+
+ {phase === 'result' && result && (
+
+
+
+ onResultSaved?.(result.url)}>
+ Use This
+
+
+ Download
+
+ window.open(result.url, '_blank', 'noopener,noreferrer')}>
+ Open
+
+
+
+ )}
+
+ {(phase === 'ready' || phase === 'idle') && (
+
+
+ No generated image yet
+ Upload an image, tune the prompt, and generate a staged render.
+
+ )}
+
+ {phase === 'error' && (
+
+ Dream Weaver unavailable
+ {error}
+ setPhase(sourceFile ? 'ready' : 'idle')}>Dismiss
+
+ )}
+
+
);
}
-
-// ── Internal: silent polling (hidden from user) ───────────────
-async function pollForResult(jobId: string): Promise
{
- 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');
-}
diff --git a/webos/src/pillars/studio/StudioPillar.tsx b/webos/src/pillars/studio/StudioPillar.tsx
index a4a5874..89cc3e7 100644
--- a/webos/src/pillars/studio/StudioPillar.tsx
+++ b/webos/src/pillars/studio/StudioPillar.tsx
@@ -107,7 +107,10 @@ function CampaignsSection() {
queryKey: ['studio-campaigns'],
queryFn: async () => {
const response = await api.get('/catalyst/campaigns?limit=100&offset=0');
- return unwrapArray(response);
+ return unwrapArray(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',
diff --git a/webos/src/shared/hooks/useStudio.ts b/webos/src/shared/hooks/useStudio.ts
index 88c5c67..bdbf1cb 100644
--- a/webos/src/shared/hooks/useStudio.ts
+++ b/webos/src/shared/hooks/useStudio.ts
@@ -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;
+}
+
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(record.images).length
- ? stableArray(record.images)
- : media.map((item) => item.url).filter((url): url is string => Boolean(url));
+ const imageUrls = stableArray(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(record.media);
+ const imageRecords = stableArray(record.images);
+ const imageStrings: InventoryMediaRecord[] = stableArray(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;
+}
diff --git a/webos/src/shared/lib/api.ts b/webos/src/shared/lib/api.ts
index bcd8019..41ef9f6 100644
--- a/webos/src/shared/lib/api.ts
+++ b/webos/src/shared/lib/api.ts
@@ -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';
diff --git a/webos/src/shared/lib/dreamWeaverApi.ts b/webos/src/shared/lib/dreamWeaverApi.ts
index 12eb40d..d9a987a 100644
--- a/webos/src/shared/lib/dreamWeaverApi.ts
+++ b/webos/src/shared/lib/dreamWeaverApi.ts
@@ -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();