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
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '@/shared/lib/apiClient';
|
|
import { stableArray, unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
|
|
|
|
/**
|
|
* useStudioProperties — Studio Pillar property listing
|
|
*/
|
|
export function useStudioProperties() {
|
|
const query = useQuery({
|
|
queryKey: ['studio-properties'],
|
|
queryFn: async () => {
|
|
const response = await api.get<unknown>('/inventory/properties?limit=100&offset=0');
|
|
const rawProperties = unwrapArray<InventoryPropertyRecord>(response, ['properties']);
|
|
return rawProperties.map(mapInventoryProperty);
|
|
},
|
|
staleTime: 120_000,
|
|
refetchOnMount: 'always',
|
|
});
|
|
return { properties: query.data ?? [], isLoading: query.isLoading };
|
|
}
|
|
|
|
/**
|
|
* useProperty — single property entity with full details
|
|
*/
|
|
export function useProperty(propertyId: string) {
|
|
const query = useQuery({
|
|
queryKey: ['property', propertyId],
|
|
queryFn: async () => {
|
|
const payload = await api.get<unknown>(`/inventory/properties/${propertyId}`);
|
|
return mapInventoryPropertyDetail(unwrapObject<InventoryPropertyRecord>(payload) ?? {});
|
|
},
|
|
staleTime: 120_000,
|
|
enabled: !!propertyId,
|
|
});
|
|
return { property: query.data, isLoading: query.isLoading };
|
|
}
|
|
|
|
// ── Types ────────────────────────────────────────────────────
|
|
export interface StudioProperty {
|
|
id: string;
|
|
name: string;
|
|
location: string;
|
|
priceRange?: string;
|
|
thumbnailUrl?: string;
|
|
availableUnits?: number;
|
|
}
|
|
|
|
export interface PropertyDetail {
|
|
id: string;
|
|
name: string;
|
|
config: string;
|
|
area: string;
|
|
price: string;
|
|
description?: string;
|
|
thumbnailUrl?: string;
|
|
interiorImageUrl?: string;
|
|
modelUrl?: string; // GLB/GLTF for R3F
|
|
images?: string[];
|
|
amenities?: string[];
|
|
}
|
|
|
|
interface InventoryPropertyRecord {
|
|
property_id?: string;
|
|
id?: string;
|
|
project_name?: string;
|
|
name?: string;
|
|
developer_name?: string;
|
|
property_type?: string;
|
|
location?: {
|
|
city?: string;
|
|
district?: string;
|
|
area?: string;
|
|
address?: string;
|
|
};
|
|
price_bands?: unknown;
|
|
unit_mix?: unknown;
|
|
amenities?: string[];
|
|
media?: unknown;
|
|
images?: unknown;
|
|
thumbnailUrl?: string;
|
|
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';
|
|
}
|
|
|
|
function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): string | undefined {
|
|
const bands = stableArray<{ label?: string; min?: number; max?: number; currency?: string }>(priceBands);
|
|
if (bands.length === 0) return undefined;
|
|
const mins = bands.map((band) => Number(band.min)).filter(Number.isFinite);
|
|
const maxes = bands.map((band) => Number(band.max)).filter(Number.isFinite);
|
|
if (!mins.length && !maxes.length) return bands[0]?.label;
|
|
const currency = bands[0]?.currency ?? 'INR';
|
|
const min = mins.length ? Math.min(...mins) : undefined;
|
|
const max = maxes.length ? Math.max(...maxes) : undefined;
|
|
if (min !== undefined && max !== undefined) return `${currency} ${min} - ${max}`;
|
|
if (min !== undefined) return `From ${currency} ${min}`;
|
|
return `Up to ${currency} ${max}`;
|
|
}
|
|
|
|
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
|
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.thumbnailUrl || item.url);
|
|
const availableUnits = unitMix.length
|
|
? unitMix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
|
|
: record.availableUnits;
|
|
const stableId = record.property_id ?? record.id ?? record.project_name ?? record.name ?? 'unknown-property';
|
|
return {
|
|
id: String(stableId),
|
|
name: record.project_name ?? record.name ?? 'Unnamed property',
|
|
location: mapLocation(record.location),
|
|
priceRange: mapPriceRange(record.price_bands),
|
|
thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.thumbnailUrl ?? mediaThumb?.url,
|
|
availableUnits,
|
|
};
|
|
}
|
|
|
|
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
|
|
const base = mapInventoryProperty(record);
|
|
const unitMix = stableArray<{ configuration?: string; area?: string; price?: string }>(record.unit_mix);
|
|
const media = normalizeMedia(record);
|
|
const primaryUnit = unitMix[0];
|
|
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;
|
|
}
|