fix: harden pipeline navigation data loading
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
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:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { QDRing } from './client360/QDRing';
|
import { QDRing } from './client360/QDRing';
|
||||||
@@ -7,7 +7,11 @@ import styles from './PipelinePillar.module.css';
|
|||||||
|
|
||||||
export default function PipelinePillar() {
|
export default function PipelinePillar() {
|
||||||
const [viewMode, setViewMode] = useState<'board' | 'list'>('board');
|
const [viewMode, setViewMode] = useState<'board' | 'list'>('board');
|
||||||
const { stages, isLoading } = useKanban();
|
const { stages, isLoading, isError, refetch } = useKanban();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -36,6 +40,8 @@ export default function PipelinePillar() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<KanbanSkeleton />
|
<KanbanSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<PipelineError onRetry={() => void refetch()} />
|
||||||
) : viewMode === 'board' ? (
|
) : viewMode === 'board' ? (
|
||||||
<KanbanBoard stages={stages} />
|
<KanbanBoard stages={stages} />
|
||||||
) : (
|
) : (
|
||||||
@@ -45,6 +51,16 @@ export default function PipelinePillar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PipelineError({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.emptyList} glass-card`}>
|
||||||
|
<strong>Pipeline data could not be loaded.</strong>
|
||||||
|
<span>Retry the CRM read model without refreshing the whole app.</span>
|
||||||
|
<button className="btn-ghost" onClick={onRetry}>Retry</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Stage {
|
interface Stage {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { QDRing } from './QDRing';
|
import { QDRing } from './QDRing';
|
||||||
import { ConversationsTab } from './tabs/Conversations';
|
import { ConversationsTab } from './tabs/Conversations';
|
||||||
@@ -30,12 +31,17 @@ const TABS: { id: Tab; label: string }[] = [
|
|||||||
export default function Client360() {
|
export default function Client360() {
|
||||||
const { personId } = useParams<{ personId: string }>();
|
const { personId } = useParams<{ personId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('conversations');
|
const [activeTab, setActiveTab] = useState<Tab>('conversations');
|
||||||
|
|
||||||
const { client, isLoading, error } = useClient360(personId!);
|
const { client, isLoading, error } = useClient360(personId!);
|
||||||
|
const backToPipeline = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['kanban'] });
|
||||||
|
navigate('/pipeline', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) return <Client360Skeleton />;
|
if (isLoading) return <Client360Skeleton />;
|
||||||
if (error || !client) return <Client360Error onBack={() => navigate('/pipeline')} />;
|
if (error || !client) return <Client360Error onBack={backToPipeline} />;
|
||||||
|
|
||||||
const qdColor =
|
const qdColor =
|
||||||
client.qdScore >= 70 ? 'var(--color-green)' :
|
client.qdScore >= 70 ? 'var(--color-green)' :
|
||||||
@@ -52,7 +58,7 @@ export default function Client360() {
|
|||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<button
|
<button
|
||||||
className={styles.backBtn}
|
className={styles.backBtn}
|
||||||
onClick={() => navigate('/pipeline')}
|
onClick={backToPipeline}
|
||||||
aria-label="Back to Pipeline"
|
aria-label="Back to Pipeline"
|
||||||
>
|
>
|
||||||
← Pipeline
|
← Pipeline
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../shared/lib/apiClient';
|
import { api } from '../../shared/lib/apiClient';
|
||||||
|
import { unwrapArray } from '../../shared/lib/apiShape';
|
||||||
import { useStudioProperties } from '../../shared/hooks/useStudio';
|
import { useStudioProperties } from '../../shared/hooks/useStudio';
|
||||||
import styles from './StudioPillar.module.css';
|
import styles from './StudioPillar.module.css';
|
||||||
|
|
||||||
@@ -101,19 +102,15 @@ interface CampaignSummary {
|
|||||||
bid_strategy: string;
|
bid_strategy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CampaignsResponse {
|
|
||||||
status: string;
|
|
||||||
data: CampaignSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function CampaignsSection() {
|
function CampaignsSection() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['studio-campaigns'],
|
queryKey: ['studio-campaigns'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get<CampaignsResponse>('/catalyst/campaigns');
|
const response = await api.get<unknown>('/catalyst/campaigns?limit=100&offset=0');
|
||||||
return response.data ?? [];
|
return unwrapArray<CampaignSummary>(response);
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
const campaigns = data ?? [];
|
const campaigns = data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/shared/lib/apiClient';
|
import { api } from '@/shared/lib/apiClient';
|
||||||
|
import { stableArray, unwrapObject } from '@/shared/lib/apiShape';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useClient360 — fetch unified client entity
|
* useClient360 — fetch unified client entity
|
||||||
@@ -9,8 +10,8 @@ export function useClient360(personId: string) {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client360', personId],
|
queryKey: ['client360', personId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
|
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||||
return mapClient360(payload.data);
|
return mapClient360(normalizeSnapshot(payload));
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
@@ -26,8 +27,8 @@ export function useConversations(personId: string) {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['conversations', personId],
|
queryKey: ['conversations', personId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
|
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||||
return mapConversationEvents(payload.data);
|
return mapConversationEvents(normalizeSnapshot(payload));
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
@@ -47,8 +48,8 @@ export function useClientProperties(personId: string) {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client-properties', personId],
|
queryKey: ['client-properties', personId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
|
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||||
return mapPropertyInterests(payload.data);
|
return mapPropertyInterests(normalizeSnapshot(payload));
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
@@ -64,8 +65,8 @@ export function useClientTasks(personId: string) {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['client-tasks', personId],
|
queryKey: ['client-tasks', personId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
|
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
|
||||||
return mapTasks(payload.data);
|
return mapTasks(normalizeSnapshot(payload));
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
enabled: !!personId,
|
enabled: !!personId,
|
||||||
@@ -144,11 +145,6 @@ interface Task {
|
|||||||
isAIGenerated?: boolean;
|
isAIGenerated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Wrapped<T> {
|
|
||||||
status: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Client360Snapshot {
|
interface Client360Snapshot {
|
||||||
client_ref?: string;
|
client_ref?: string;
|
||||||
identity?: {
|
identity?: {
|
||||||
@@ -203,6 +199,22 @@ interface Client360Snapshot {
|
|||||||
recommended_next_actions?: string[];
|
recommended_next_actions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshot(payload: unknown): Client360Snapshot {
|
||||||
|
const snapshot = unwrapObject<Client360Snapshot>(payload);
|
||||||
|
if (!snapshot) return {};
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
identity: snapshot.identity ?? {},
|
||||||
|
current_lead: snapshot.current_lead ?? {},
|
||||||
|
active_opportunities: stableArray(snapshot.active_opportunities),
|
||||||
|
recent_interactions: stableArray(snapshot.recent_interactions),
|
||||||
|
property_interests: stableArray(snapshot.property_interests),
|
||||||
|
tasks: stableArray(snapshot.tasks),
|
||||||
|
risk_flags: stableArray(snapshot.risk_flags),
|
||||||
|
recommended_next_actions: stableArray(snapshot.recommended_next_actions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function scoreToPercent(value: number | null | undefined): number {
|
function scoreToPercent(value: number | null | undefined): number {
|
||||||
const safe = Number(value ?? 0);
|
const safe = Number(value ?? 0);
|
||||||
const pct = safe <= 1 ? safe * 100 : safe;
|
const pct = safe <= 1 ? safe * 100 : safe;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/shared/lib/apiClient';
|
import { api } from '@/shared/lib/apiClient';
|
||||||
|
import { unwrapArray } from '@/shared/lib/apiShape';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useKanban — Pipeline Pillar kanban board data
|
* useKanban — Pipeline Pillar kanban board data
|
||||||
@@ -8,11 +9,23 @@ import { api } from '@/shared/lib/apiClient';
|
|||||||
export function useKanban() {
|
export function useKanban() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['kanban'],
|
queryKey: ['kanban'],
|
||||||
queryFn: () => api.get<KanbanStage[]>('/crm/pipeline/kanban'),
|
queryFn: async () => {
|
||||||
|
const payload = await api.get<unknown>('/crm/pipeline/kanban?limit=250&offset=0');
|
||||||
|
return unwrapArray<RawKanbanStage>(payload).map(normalizeStage);
|
||||||
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retry: 1,
|
||||||
});
|
});
|
||||||
return { stages: query.data ?? [], isLoading: query.isLoading };
|
return {
|
||||||
|
stages: query.data ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isError: query.isError,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KanbanStage {
|
export interface KanbanStage {
|
||||||
@@ -32,3 +45,36 @@ export interface KanbanLead {
|
|||||||
lastContactChannel: string;
|
lastContactChannel: string;
|
||||||
isVaultActive?: boolean;
|
isVaultActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawKanbanStage {
|
||||||
|
id?: string;
|
||||||
|
stage?: string;
|
||||||
|
status?: string;
|
||||||
|
label?: string;
|
||||||
|
emoji?: string;
|
||||||
|
leads?: unknown;
|
||||||
|
items?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStage(stage: RawKanbanStage): KanbanStage {
|
||||||
|
const id = String(stage.id ?? stage.stage ?? stage.status ?? 'new');
|
||||||
|
const leads = unwrapArray<KanbanLead>(stage.leads ?? stage.items ?? stage.data).map(normalizeLead);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(stage.label ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
|
||||||
|
emoji: String(stage.emoji ?? id.slice(0, 1).toUpperCase()),
|
||||||
|
leads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLead(lead: Partial<KanbanLead>): KanbanLead {
|
||||||
|
return {
|
||||||
|
...lead,
|
||||||
|
id: String(lead.id),
|
||||||
|
name: String(lead.name ?? 'Unnamed Client'),
|
||||||
|
qdScore: Number.isFinite(Number(lead.qdScore)) ? Number(lead.qdScore) : 0,
|
||||||
|
lastContactRelative: String(lead.lastContactRelative ?? 'No contact yet'),
|
||||||
|
lastContactChannel: String(lead.lastContactChannel ?? 'crm'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/shared/lib/apiClient';
|
import { api } from '@/shared/lib/apiClient';
|
||||||
|
import { stableArray, unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useStudioProperties — Studio Pillar property listing
|
* useStudioProperties — Studio Pillar property listing
|
||||||
@@ -8,15 +9,12 @@ export function useStudioProperties() {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['studio-properties'],
|
queryKey: ['studio-properties'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get<InventoryPropertiesResponse | StudioProperty[]>('/inventory/properties');
|
const response = await api.get<unknown>('/inventory/properties?limit=100&offset=0');
|
||||||
const rawProperties = Array.isArray(response)
|
const rawProperties = unwrapArray<InventoryPropertyRecord>(response, ['properties']);
|
||||||
? response
|
|
||||||
: Array.isArray(response.properties)
|
|
||||||
? response.properties
|
|
||||||
: [];
|
|
||||||
return rawProperties.map(mapInventoryProperty);
|
return rawProperties.map(mapInventoryProperty);
|
||||||
},
|
},
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
return { properties: query.data ?? [], isLoading: query.isLoading };
|
return { properties: query.data ?? [], isLoading: query.isLoading };
|
||||||
}
|
}
|
||||||
@@ -27,9 +25,10 @@ export function useStudioProperties() {
|
|||||||
export function useProperty(propertyId: string) {
|
export function useProperty(propertyId: string) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['property', propertyId],
|
queryKey: ['property', propertyId],
|
||||||
queryFn: async () => mapInventoryPropertyDetail(
|
queryFn: async () => {
|
||||||
await api.get<InventoryPropertyRecord>(`/inventory/properties/${propertyId}`)
|
const payload = await api.get<unknown>(`/inventory/properties/${propertyId}`);
|
||||||
),
|
return mapInventoryPropertyDetail(unwrapObject<InventoryPropertyRecord>(payload) ?? {});
|
||||||
|
},
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
enabled: !!propertyId,
|
enabled: !!propertyId,
|
||||||
});
|
});
|
||||||
@@ -60,10 +59,6 @@ export interface PropertyDetail {
|
|||||||
amenities?: string[];
|
amenities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InventoryPropertiesResponse {
|
|
||||||
properties?: InventoryPropertyRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InventoryPropertyRecord {
|
interface InventoryPropertyRecord {
|
||||||
property_id?: string;
|
property_id?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -77,11 +72,11 @@ interface InventoryPropertyRecord {
|
|||||||
area?: string;
|
area?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
};
|
};
|
||||||
price_bands?: Array<{ label?: string; min?: number; max?: number; currency?: string }>;
|
price_bands?: unknown;
|
||||||
unit_mix?: Array<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>;
|
unit_mix?: unknown;
|
||||||
amenities?: string[];
|
amenities?: string[];
|
||||||
media?: Array<{ url?: string; thumbnail_url?: string }>;
|
media?: unknown;
|
||||||
images?: string[];
|
images?: unknown;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
availableUnits?: number;
|
availableUnits?: number;
|
||||||
}
|
}
|
||||||
@@ -92,11 +87,12 @@ function mapLocation(location: InventoryPropertyRecord['location']): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): string | undefined {
|
function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): string | undefined {
|
||||||
if (!Array.isArray(priceBands) || priceBands.length === 0) return undefined;
|
const bands = stableArray<{ label?: string; min?: number; max?: number; currency?: string }>(priceBands);
|
||||||
const mins = priceBands.map((band) => Number(band.min)).filter(Number.isFinite);
|
if (bands.length === 0) return undefined;
|
||||||
const maxes = priceBands.map((band) => Number(band.max)).filter(Number.isFinite);
|
const mins = bands.map((band) => Number(band.min)).filter(Number.isFinite);
|
||||||
if (!mins.length && !maxes.length) return priceBands[0]?.label;
|
const maxes = bands.map((band) => Number(band.max)).filter(Number.isFinite);
|
||||||
const currency = priceBands[0]?.currency ?? 'INR';
|
if (!mins.length && !maxes.length) return bands[0]?.label;
|
||||||
|
const currency = bands[0]?.currency ?? 'INR';
|
||||||
const min = mins.length ? Math.min(...mins) : undefined;
|
const min = mins.length ? Math.min(...mins) : undefined;
|
||||||
const max = maxes.length ? Math.max(...maxes) : undefined;
|
const max = maxes.length ? Math.max(...maxes) : undefined;
|
||||||
if (min !== undefined && max !== undefined) return `${currency} ${min} - ${max}`;
|
if (min !== undefined && max !== undefined) return `${currency} ${min} - ${max}`;
|
||||||
@@ -105,9 +101,11 @@ function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
||||||
const mediaThumb = Array.isArray(record.media) ? record.media.find((item) => item.thumbnail_url || item.url) : undefined;
|
const media = stableArray<{ url?: string; thumbnail_url?: string }>(record.media);
|
||||||
const availableUnits = Array.isArray(record.unit_mix)
|
const unitMix = stableArray<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>(record.unit_mix);
|
||||||
? record.unit_mix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
|
const mediaThumb = media.find((item) => item.thumbnail_url || item.url);
|
||||||
|
const availableUnits = unitMix.length
|
||||||
|
? unitMix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
|
||||||
: record.availableUnits;
|
: record.availableUnits;
|
||||||
const stableId = record.property_id ?? record.id ?? record.project_name ?? record.name ?? 'unknown-property';
|
const stableId = record.property_id ?? record.id ?? record.project_name ?? record.name ?? 'unknown-property';
|
||||||
return {
|
return {
|
||||||
@@ -122,12 +120,12 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
|
|||||||
|
|
||||||
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
|
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
|
||||||
const base = mapInventoryProperty(record);
|
const base = mapInventoryProperty(record);
|
||||||
const primaryUnit = Array.isArray(record.unit_mix) ? record.unit_mix[0] : undefined;
|
const unitMix = stableArray<{ configuration?: string; area?: string; price?: string }>(record.unit_mix);
|
||||||
const images = Array.isArray(record.images)
|
const media = stableArray<{ url?: string }>(record.media);
|
||||||
? record.images
|
const primaryUnit = unitMix[0];
|
||||||
: Array.isArray(record.media)
|
const images = stableArray<string>(record.images).length
|
||||||
? record.media.map((item) => item.url).filter((url): url is string => Boolean(url))
|
? stableArray<string>(record.images)
|
||||||
: [];
|
: media.map((item) => item.url).filter((url): url is string => Boolean(url));
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration',
|
config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration',
|
||||||
|
|||||||
37
webos/src/shared/lib/apiShape.ts
Normal file
37
webos/src/shared/lib/apiShape.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the response shapes used across the legacy Velocity API, the newer
|
||||||
|
* WebOS API, and paginated read endpoints. Components should render arrays,
|
||||||
|
* not transport envelopes.
|
||||||
|
*/
|
||||||
|
export function unwrapArray<T>(payload: unknown, keys: string[] = []): T[] {
|
||||||
|
if (Array.isArray(payload)) return payload as T[];
|
||||||
|
if (!isRecord(payload)) return [];
|
||||||
|
|
||||||
|
for (const key of [...keys, 'data', 'items', 'results', 'records', 'rows', 'value']) {
|
||||||
|
const candidate = payload[key];
|
||||||
|
if (Array.isArray(candidate)) return candidate as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapObject<T>(payload: unknown, keys: string[] = []): T | undefined {
|
||||||
|
if (!isRecord(payload)) return undefined;
|
||||||
|
|
||||||
|
for (const key of [...keys, 'data', 'record', 'result', 'item', 'value']) {
|
||||||
|
const candidate = payload[key];
|
||||||
|
if (isRecord(candidate)) return candidate as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableArray<T>(value: unknown): T[] {
|
||||||
|
return Array.isArray(value) ? value as T[] : [];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user