Initial commit: Velocity-OS migration

This commit is contained in:
2026-05-01 12:32:19 +05:30
commit 407af828d4
283 changed files with 207782 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
/* Client360 */
.root { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.backBtn { align-self: flex-start; background: none; border: none; color: var(--color-text-tertiary); font-size: var(--text-sm); cursor: pointer; padding: var(--space-4) var(--space-6) 0; font-family: var(--font-sans); transition: color var(--duration-fast) var(--ease-standard); }
.backBtn:hover { color: var(--color-text-primary); }
/* Header */
.header { margin: var(--space-3) var(--space-6) 0; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-5); }
.headerTop { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.identity { display: flex; align-items: center; gap: var(--space-4); }
.avatar { width: 52px; height: 52px; border-radius: var(--radius-full); background: var(--color-violet-glow); border: 2px solid var(--color-violet); display: flex; align-items: center; justify-content: center; font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-violet-light); flex-shrink: 0; overflow: hidden; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.nameBlock { display: flex; flex-direction: column; gap: var(--space-1); }
.name { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); letter-spacing: var(--tracking-tight); margin: 0; }
.meta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
.intentBadge { flex-shrink: 0; align-self: flex-start; }
/* KPI row */
.kpiRow { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
.kpiChip { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); }
.kpiLabel { font-size: 9px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.qdWrap { display: flex; align-items: center; gap: var(--space-3); }
.qdValue { font-size: var(--text-2xl); font-weight: var(--font-bold); }
.qdDelta { font-size: 10px; display: block; }
.kpiValue { font-size: var(--text-lg); font-weight: var(--font-semibold); color: var(--color-text-primary); }
.kpiSub { font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Action bar */
.actionBar { display: flex; gap: var(--space-3); flex-wrap: wrap; }
/* Tabs */
.tabNav { display: flex; gap: 0; border-bottom: var(--glass-border); margin: var(--space-4) var(--space-6) 0; }
.tabBtn { position: relative; background: none; border: none; padding: var(--space-3) var(--space-5); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--color-text-tertiary); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.tabActive { color: var(--color-text-primary); }
.tabUnderline { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: var(--color-violet); border-radius: 1px; }
/* Content */
.tabContent { padding: var(--space-5) var(--space-6) var(--space-10); flex: 1; }

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { QDRing } from './QDRing';
import { ConversationsTab } from './tabs/Conversations';
import { IntelligenceTab } from './tabs/Intelligence';
import { PropertiesTab } from './tabs/Properties';
import { TasksTab } from './tabs/Tasks';
import { useClient360 } from '../../../shared/hooks/useClient360';
import styles from './Client360.module.css';
/**
* Client360
* The unified client entity. The most important screen in Velocity-OS.
* Accessible at /pipeline/:personId — slides in with depth choreography.
*
* Four tabs only: Conversations | Intelligence | Properties | Tasks
* Header: glassmorphic, always visible with QD ring, pipeline stage, last contact.
*/
type Tab = 'conversations' | 'intelligence' | 'properties' | 'tasks';
const TABS: { id: Tab; label: string }[] = [
{ id: 'conversations', label: 'Conversations' },
{ id: 'intelligence', label: 'Intelligence' },
{ id: 'properties', label: 'Properties' },
{ id: 'tasks', label: 'Tasks' },
];
export default function Client360() {
const { personId } = useParams<{ personId: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>('conversations');
const { client, isLoading, error } = useClient360(personId!);
if (isLoading) return <Client360Skeleton />;
if (error || !client) return <Client360Error onBack={() => navigate('/pipeline')} />;
const qdColor =
client.qdScore >= 70 ? 'var(--color-green)' :
client.qdScore >= 40 ? 'var(--color-amber)' :
'var(--color-red)';
const intentLabel =
client.qdScore >= 70 ? 'HIGH INTENT' :
client.qdScore >= 40 ? 'MODERATE' :
'LOW INTENT';
return (
<div className={styles.root}>
{/* Back button */}
<button
className={styles.backBtn}
onClick={() => navigate('/pipeline')}
aria-label="Back to Pipeline"
>
Pipeline
</button>
{/* ── Glassmorphic Header ─────────────────────────────── */}
<div className={`${styles.header} glass-heavy`}>
<div className={styles.headerTop}>
{/* Avatar + Identity */}
<div className={styles.identity}>
<div className={styles.avatar}>
{client.avatarUrl
? <img src={client.avatarUrl} alt={client.name} />
: <span>{client.name.slice(0, 1)}</span>
}
</div>
<div className={styles.nameBlock}>
<h1 className={styles.name}>{client.name}</h1>
<p className={styles.meta}>
{client.location}
{client.primaryPhone && ` · ${client.primaryPhone}`}
</p>
</div>
</div>
{/* Intent badge */}
<span className={`badge badge-high-intent ${styles.intentBadge}`}
style={{ borderColor: qdColor, color: qdColor }}>
<span className="live-dot" style={{ background: qdColor }} />
{intentLabel}
</span>
</div>
{/* ── Three KPI chips ──────────────────────────────── */}
<div className={styles.kpiRow}>
{/* QD Score chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>QD SCORE</span>
<div className={styles.qdWrap}>
<QDRing score={client.qdScore} size={48} color={qdColor} />
<div>
<span className={styles.qdValue}
style={{ color: qdColor }}>{client.qdScore}</span>
{client.qdDelta !== 0 && (
<span className={styles.qdDelta}
style={{ color: client.qdDelta > 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{client.qdDelta > 0 ? '+' : ''}{client.qdDelta} this week
</span>
)}
</div>
</div>
</div>
{/* Pipeline Stage chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>PIPELINE STAGE</span>
<span className={styles.kpiValue}>
{client.stageEmoji} {client.stageName}
</span>
</div>
{/* Last Contact chip */}
<div className={`${styles.kpiChip} glass`}>
<span className={styles.kpiLabel}>LAST CONTACT</span>
<span className={styles.kpiValue}>{client.lastContactRelative}</span>
<span className={styles.kpiSub}>via {client.lastContactChannel}</span>
</div>
</div>
{/* ── Primary action bar ───────────────────────────── */}
<div className={styles.actionBar}>
<ActionButton
icon="💬"
label={client.lastContactChannel === 'WhatsApp' ? 'WhatsApp' : 'Message'}
onClick={() => {/* open inline compose */}}
primary
/>
<ActionButton icon="📞" label="Call" onClick={() => window.open(`tel:${client.primaryPhone}`)} />
<ActionButton icon="📅" label="Schedule" onClick={() => {/* open task modal */}} />
<ActionButton icon="⋯" label="More" onClick={() => {/* slide-up sheet */}} />
</div>
</div>
{/* ── Tab Navigation ──────────────────────────────────── */}
<div className={styles.tabNav} role="tablist">
{TABS.map(({ id, label }) => (
<button
key={id}
role="tab"
aria-selected={activeTab === id}
className={`${styles.tabBtn} ${activeTab === id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(id)}
>
{label}
{activeTab === id && (
<motion.div
layoutId="tab-underline"
className={styles.tabUnderline}
transition={{ type: 'spring', stiffness: 500, damping: 40 }}
/>
)}
</button>
))}
</div>
{/* ── Tab Content ─────────────────────────────────────── */}
<div className={styles.tabContent}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
{activeTab === 'conversations' && <ConversationsTab personId={personId!} />}
{activeTab === 'intelligence' && <IntelligenceTab client={client} />}
{activeTab === 'properties' && <PropertiesTab personId={personId!} />}
{activeTab === 'tasks' && <TasksTab personId={personId!} />}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
// ── Sub-components ───────────────────────────────────────────
function ActionButton({
icon, label, onClick, primary = false,
}: { icon: string; label: string; onClick: () => void; primary?: boolean }) {
return (
<button
className={primary ? 'btn-primary' : 'btn-ghost'}
onClick={onClick}
aria-label={label}
>
<span>{icon}</span> {label}
</button>
);
}
function Client360Skeleton() {
return (
<div className={styles.root}>
<div className={`${styles.header} glass-heavy shimmer`} style={{ minHeight: 220 }} />
<div style={{ padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{[1,2,3].map(i => (
<div key={i} className="glass shimmer" style={{ height: 80, borderRadius: 'var(--radius-lg)' }} />
))}
</div>
</div>
);
}
function Client360Error({ onBack }: { onBack: () => void }) {
return (
<div className={styles.root} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--space-4)', padding: 'var(--space-16)' }}>
<p style={{ color: 'var(--color-text-secondary)' }}>Could not load client profile.</p>
<button className="btn-ghost" onClick={onBack}> Back to Pipeline</button>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
/**
* QDRing
* Animated SVG arc representing the Qualification Desire score (0100).
* Color: green (≥70), amber (4069), red (<40).
* Animates from 0 to score on mount; smooth spring on value change.
*/
interface QDRingProps {
score: number;
size?: number; // diameter in px (default 64)
strokeWidth?: number;
color?: string; // overrides semantic color if provided
showLabel?: boolean;
}
export function QDRing({
score,
size = 64,
strokeWidth = 4,
color,
showLabel = false,
}: QDRingProps) {
const clampedScore = Math.max(0, Math.min(100, score));
const r = (size - strokeWidth) / 2;
const cx = size / 2;
const cy = size / 2;
const circumference = 2 * Math.PI * r;
// Arc: 0% = full stroke-dashoffset (hidden), 100% = 0 offset (full)
const offset = circumference * (1 - clampedScore / 100);
// Semantic color
const arcColor = color ?? (
clampedScore >= 70 ? 'var(--color-green)' :
clampedScore >= 40 ? 'var(--color-amber)' :
'var(--color-red)'
);
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={`QD Score: ${clampedScore}`}
style={{ transform: 'rotate(-90deg)' }} // Start arc at top
>
{/* Track */}
<circle
cx={cx} cy={cy} r={r}
fill="none"
stroke="rgba(255,255,255,0.08)"
strokeWidth={strokeWidth}
/>
{/* Animated arc */}
<motion.circle
cx={cx} cy={cy} r={r}
fill="none"
stroke={arcColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
style={{
filter: `drop-shadow(0 0 4px ${arcColor})`,
}}
/>
{/* Optional center label (shown rotated back) */}
{showLabel && (
<text
x={cx} y={cy}
textAnchor="middle"
dominantBaseline="middle"
fill={arcColor}
fontSize={size * 0.22}
fontWeight="700"
fontFamily="var(--font-sans)"
style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px` }}
>
{clampedScore}
</text>
)}
</svg>
);
}

View File

@@ -0,0 +1,26 @@
/* Conversations tab */
.root { display: flex; flex-direction: column; gap: var(--space-3); }
.skeleton { height: 100px; border-radius: var(--radius-lg); }
.eventCard { display: flex; flex-direction: column; gap: var(--space-3); }
.eventHeader { display: flex; align-items: center; justify-content: space-between; }
.channelBadge { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-text-secondary); }
.timestamp { font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Thread */
.thread { display: flex; flex-direction: column; gap: var(--space-2); }
.bubble { display: flex; align-items: flex-end; gap: var(--space-2); max-width: 80%; }
.inbound { align-self: flex-start; }
.outbound { align-self: flex-end; flex-direction: row-reverse; }
.bubbleText { background: var(--glass-bg); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-size: var(--text-sm); color: var(--color-text-primary); }
.outbound .bubbleText { background: var(--color-violet); color: white; }
.status { font-size: 10px; color: var(--color-text-tertiary); }
.threadActions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
/* Reply */
.replyBox { display: flex; gap: var(--space-2); align-items: center; margin-top: var(--space-2); }
.replyInput { flex: 1; background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-primary); outline: none; }
/* Call */
.callRecord { display: flex; flex-direction: column; gap: var(--space-2); }
.callMeta { display: flex; gap: var(--space-3); font-size: var(--text-xs); color: var(--color-text-secondary); }
.keyMoments { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.kmLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
.kmChip { font-size: var(--text-xs); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-full); padding: 2px var(--space-2); color: var(--color-text-secondary); }
.callActions { display: flex; gap: var(--space-2); }

View File

@@ -0,0 +1,147 @@
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { useConversations } from '../../../shared/hooks/useClient360';
import styles from './Conversations.module.css';
/**
* Conversations Tab
* Unified chronological feed: WhatsApp threads + call records + emails.
* All in one scroll — no navigation to separate Comms module ever needed.
* WhatsApp threads render inline with reply. Calls show key moments.
*/
interface ConversationsTabProps {
personId: string;
}
type EventType = 'whatsapp' | 'call' | 'email';
interface ConvEvent {
id: string;
type: EventType;
timestamp: string;
timestampRelative: string;
// WhatsApp
messages?: { sender: 'client' | 'you'; text: string; status?: '✓' | '✓✓' }[];
// Call
duration?: string;
direction?: 'inbound' | 'outbound';
keyMoments?: string[];
hasTranscript?: boolean;
// Email
subject?: string;
preview?: string;
}
export function ConversationsTab({ personId }: ConversationsTabProps) {
const { events, isLoading, sendWhatsApp } = useConversations(personId);
const [replyText, setReplyText] = useState('');
const [replyingToId, setReplyingToId] = useState<string | null>(null);
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1, 2].map(i => (
<div key={i} className={`${styles.skeleton} shimmer`} />
))}
</div>
);
}
return (
<div className={styles.root}>
{events.map((event, i) => (
<motion.div
key={event.id}
className={`${styles.eventCard} glass-card`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.25 }}
>
{/* Event header */}
<div className={styles.eventHeader}>
<span className={styles.channelBadge}
data-type={event.type}>
{event.type === 'whatsapp' ? '💬 WhatsApp'
: event.type === 'call' ? '📞 Call'
: '✉️ Email'}
</span>
<span className={styles.timestamp}>{event.timestampRelative}</span>
</div>
{/* WhatsApp thread */}
{event.type === 'whatsapp' && event.messages && (
<div className={styles.thread}>
{event.messages.map((msg, mi) => (
<div
key={mi}
className={`${styles.bubble} ${msg.sender === 'you' ? styles.outbound : styles.inbound}`}
>
<span className={styles.bubbleText}>{msg.text}</span>
{msg.sender === 'you' && msg.status && (
<span className={styles.status}>{msg.status}</span>
)}
</div>
))}
{/* Inline reply */}
{replyingToId === event.id ? (
<div className={styles.replyBox}>
<input
value={replyText}
onChange={e => setReplyText(e.target.value)}
placeholder="Type a reply…"
className={styles.replyInput}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter' && replyText.trim()) {
sendWhatsApp(replyText);
setReplyText('');
setReplyingToId(null);
}
}}
/>
<button className="btn-primary" onClick={() => {
if (replyText.trim()) { sendWhatsApp(replyText); setReplyText(''); setReplyingToId(null); }
}}>Send</button>
<button className="btn-ghost" onClick={() => setReplyingToId(null)}>Cancel</button>
</div>
) : (
<div className={styles.threadActions}>
<button className="btn-ghost" onClick={() => setReplyingToId(event.id)}>
Reply in WhatsApp
</button>
<button className="btn-ghost">AI Summarize</button>
</div>
)}
</div>
)}
{/* Call record */}
{event.type === 'call' && (
<div className={styles.callRecord}>
<div className={styles.callMeta}>
<span>{event.direction === 'inbound' ? '↙ Inbound' : '↗ Outbound'}</span>
{event.duration && <span>· {event.duration}</span>}
{event.hasTranscript && <span>· Transcript available</span>}
</div>
{event.keyMoments && event.keyMoments.length > 0 && (
<div className={styles.keyMoments}>
<span className={styles.kmLabel}>Key moments:</span>
{event.keyMoments.map((m, mi) => (
<span key={mi} className={styles.kmChip}>{m}</span>
))}
</div>
)}
{event.hasTranscript && (
<div className={styles.callActions}>
<button className="btn-ghost">Read Transcript</button>
<button className="btn-ghost">View Extracted Facts</button>
</div>
)}
</div>
)}
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,17 @@
/* Intelligence tab */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.insightCard { display: flex; flex-direction: column; gap: var(--space-3); }
.insightHeader { display: flex; align-items: center; justify-content: space-between; }
.aiStar { font-size: var(--text-xs); font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); color: var(--color-violet-light); }
.insightText { font-size: var(--text-base); color: var(--color-text-primary); line-height: var(--leading-relaxed); margin: 0; }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.sectionTitle { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); }
.chipsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-2); }
.factChip { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-3); border-radius: var(--radius-lg); }
.chipLabel { font-size: 10px; color: var(--color-text-tertiary); font-weight: var(--font-medium); }
.chipValue { font-size: var(--text-sm); color: var(--color-text-primary); font-weight: var(--font-semibold); }
.objectionList { display: flex; flex-direction: column; gap: var(--space-2); list-style: none; padding: 0; margin: 0; }
.objectionItem { font-size: var(--text-sm); color: var(--color-text-secondary); }
.biometricSection { }
.sparkline { width: 100%; height: 64px; }
.peakLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: var(--space-1) 0 0; }

View File

@@ -0,0 +1,150 @@
import { motion } from 'framer-motion';
import { QDRing } from '../QDRing';
import styles from './Intelligence.module.css';
/**
* Intelligence Tab
* Biometric data expressed as business meaning — never raw scores.
* Sections: AI Insight (act-able) | Extracted Facts chips | Objections | Biometric sparkline
*/
interface IntelligenceTabProps {
client: {
id: string;
aiInsight?: string;
extractedFacts?: { budget?: string; timeline?: string; decisionMakers?: string; [key: string]: string | undefined };
objections?: string[];
qdHistory?: { date: string; score: number; label?: string }[];
qdScore: number;
};
}
export function IntelligenceTab({ client }: IntelligenceTabProps) {
const facts = client.extractedFacts ?? {};
const factChips = [
{ label: 'Budget', value: facts.budget },
{ label: 'Timeline', value: facts.timeline },
{ label: 'Decision Makers', value: facts.decisionMakers },
{ label: 'Property Type', value: facts.propertyType },
{ label: 'Location Pref', value: facts.locationPreference },
].filter(f => f.value);
return (
<div className={styles.root}>
{/* AI Insight — always first */}
{client.aiInsight && (
<motion.div
className={`${styles.insightCard} glass-card glass-violet`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className={styles.insightHeader}>
<span className={styles.aiStar}> AI INSIGHT</span>
<button className="btn-primary">Act</button>
</div>
<p className={styles.insightText}>{client.aiInsight}</p>
</motion.div>
)}
{/* Extracted Facts chips */}
{factChips.length > 0 && (
<motion.div
className={styles.section}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>EXTRACTED FACTS</h3>
<div className={styles.chipsGrid}>
{factChips.map(({ label, value }, i) => (
<motion.div
key={label}
className={`${styles.factChip} glass`}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.15 + i * 0.05 }}
>
<span className={styles.chipLabel}>{label}</span>
<span className={styles.chipValue}>{value}</span>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Objections */}
{client.objections && client.objections.length > 0 && (
<motion.div
className={styles.section}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>OBJECTIONS RAISED</h3>
<ul className={styles.objectionList}>
{client.objections.map((obj, i) => (
<li key={i} className={styles.objectionItem}>· {obj}</li>
))}
</ul>
</motion.div>
)}
{/* Biometric Engagement sparkline */}
{client.qdHistory && client.qdHistory.length > 0 && (
<motion.div
className={`${styles.section} ${styles.biometricSection} glass-card`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
>
<h3 className={styles.sectionTitle}>BIOMETRIC ENGAGEMENT</h3>
<QDSparkline history={client.qdHistory} currentScore={client.qdScore} />
{client.qdHistory.slice(-1)[0]?.label && (
<p className={styles.peakLabel}>
Peak: {client.qdHistory.slice(-1)[0].label}
</p>
)}
</motion.div>
)}
</div>
);
}
// ── QD Sparkline (inline SVG, no charting library dependency) ─
function QDSparkline({
history,
currentScore,
}: { history: { date: string; score: number; label?: string }[]; currentScore: number }) {
if (history.length < 2) return null;
const scores = history.map(h => h.score);
const min = Math.min(...scores);
const max = Math.max(...scores);
const range = max - min || 1;
const W = 320, H = 64, PAD = 8;
const step = (W - PAD * 2) / (scores.length - 1);
const points = scores.map((s, i) => ({
x: PAD + i * step,
y: H - PAD - ((s - min) / range) * (H - PAD * 2),
}));
const pathD = points.map((p, i) =>
(i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)
).join(' ');
const qdColor = currentScore >= 70 ? 'var(--color-green)'
: currentScore >= 40 ? 'var(--color-amber)'
: 'var(--color-red)';
return (
<svg viewBox={`0 0 ${W} ${H}`} className={styles.sparkline}>
<path d={pathD} fill="none" stroke={qdColor} strokeWidth="2" strokeLinecap="round" />
{/* Peak dot */}
{points.map((p, i) => scores[i] === max && (
<circle key={i} cx={p.x} cy={p.y} r="4"
fill={qdColor} opacity="0.9" />
))}
</svg>
);
}

View File

@@ -0,0 +1,15 @@
/* Properties tab */
.root { display: flex; flex-direction: column; gap: var(--space-4); }
.skeleton { height: 120px; border-radius: var(--radius-lg); }
.propertyCard { display: flex; flex-direction: column; gap: var(--space-4); overflow: hidden; }
.thumbnail { position: relative; height: 140px; border-radius: var(--radius-lg); overflow: hidden; background: var(--glass-bg); }
.thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.thumbPlaceholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 40px; }
.primaryBadge { position: absolute; top: var(--space-2); right: var(--space-2); }
.details { display: flex; flex-direction: column; gap: var(--space-2); }
.unitName { font-size: var(--text-base); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0; }
.unitMeta { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; }
.engagementLabel { font-size: var(--text-xs); font-weight: var(--font-semibold); margin: 0; }
.propActions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.reimagineWrap { padding-top: var(--space-4); }
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,121 @@
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { ReimaginePanel } from '../../../studio/ReimaginePanel';
import { useClientProperties } from '../../../../shared/hooks/useClient360';
import styles from './Properties.module.css';
/**
* Properties Tab
* Shows property interests linked to this client.
* "Stage It" triggers ReimaginePanel inline — no navigation to Studio/Catalyst.
* "Share Brochure" generates Vault link — one tap, no modal.
*/
interface PropertiesTabProps {
personId: string;
}
export function PropertiesTab({ personId }: PropertiesTabProps) {
const navigate = useNavigate();
const { properties, isLoading } = useClientProperties(personId);
const [reimagining, setReimagining] = useState<string | null>(null); // propertyId
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
</div>
);
}
return (
<div className={styles.root}>
{properties.map((prop, i) => (
<motion.div
key={prop.id}
className={`${styles.propertyCard} glass-card`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, duration: 0.3 }}
>
{/* Thumbnail */}
<div className={styles.thumbnail}>
{prop.thumbnailUrl
? <img src={prop.thumbnailUrl} alt={prop.unitName} />
: <div className={styles.thumbPlaceholder}>🏙</div>
}
{prop.isPrimary && (
<span className={`badge badge-high-intent ${styles.primaryBadge}`}>
Primary
</span>
)}
</div>
{/* Details */}
<div className={styles.details}>
<h3 className={styles.unitName}>{prop.projectName} · {prop.unitName}</h3>
<p className={styles.unitMeta}>
{prop.config} · {prop.area} · {prop.price}
</p>
<p className={styles.engagementLabel}
style={{ color: prop.engagementLevel === 'High' ? 'var(--color-green)' : 'var(--color-text-secondary)' }}>
{prop.engagementLevel} Engagement
</p>
{/* Actions */}
<div className={styles.propActions}>
<button
className="btn-ghost"
onClick={() => navigate(`/studio/${prop.id}`)}
>
View Unit
</button>
<button
className="btn-ghost"
onClick={() => {/* generate vault link + copy */}}
>
Share Brochure
</button>
<button
className="btn-primary"
onClick={() => setReimagining(
reimagining === prop.id ? null : prop.id
)}
>
Stage It
</button>
</div>
</div>
{/* Inline ReimaginePanel — no navigation to Studio */}
<AnimatedCollapse open={reimagining === prop.id}>
<div className={styles.reimagineWrap}>
<ReimaginePanel
propertyId={prop.id}
roomImageUrl={prop.thumbnailUrl}
onResultSaved={() => setReimagining(null)}
/>
</div>
</AnimatedCollapse>
</motion.div>
))}
{properties.length === 0 && (
<p className={styles.empty}>No property interests linked yet.</p>
)}
</div>
);
}
function AnimatedCollapse({ open, children }: { open: boolean; children: React.ReactNode }) {
return (
<motion.div
style={{ overflow: 'hidden' }}
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,14 @@
/* Tasks tab */
.root { display: flex; flex-direction: column; gap: var(--space-5); }
.skeleton { height: 56px; border-radius: var(--radius-md); }
.group { display: flex; flex-direction: column; gap: var(--space-2); }
.groupLabel { font-size: 10px; font-weight: var(--font-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--color-text-tertiary); padding-bottom: var(--space-1); border-bottom: var(--glass-border); }
.taskRow { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
.checkOpen { color: var(--color-text-tertiary); font-size: 14px; flex-shrink: 0; }
.checkDone { color: var(--color-green); font-size: 14px; flex-shrink: 0; }
.taskContent { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.taskLabel { font-size: var(--text-sm); color: var(--color-text-primary); }
.taskDue { font-size: 10px; color: var(--color-text-tertiary); }
.aiChip { font-size: 10px; background: rgba(124,58,237,0.15); color: var(--color-violet-light); border-radius: var(--radius-full); padding: 1px var(--space-2); display: inline-block; }
.taskActions { display: flex; gap: var(--space-1); flex-shrink: 0; }
.empty { font-size: var(--text-sm); color: var(--color-text-tertiary); }

View File

@@ -0,0 +1,104 @@
import { motion } from 'framer-motion';
import { useClientTasks } from '../../../../shared/hooks/useClient360';
import styles from './Tasks.module.css';
/**
* Tasks Tab
* Timeline: TODAY → UPCOMING → COMPLETED
* Tasks are AI-generated or manually created reminders.
* "Done" and "Snooze" are the only actions — no task configuration UI.
*/
interface TasksTabProps {
personId: string;
}
export function TasksTab({ personId }: TasksTabProps) {
const { tasks, isLoading, markDone, snooze } = useClientTasks(personId);
const today = tasks.filter(t => t.group === 'today');
const upcoming = tasks.filter(t => t.group === 'upcoming');
const completed = tasks.filter(t => t.group === 'completed');
if (isLoading) {
return (
<div className={styles.root}>
{[0, 1, 2].map(i => <div key={i} className={`${styles.skeleton} shimmer`} />)}
</div>
);
}
return (
<div className={styles.root}>
{today.length > 0 && (
<TaskGroup label="TODAY" tasks={today} onDone={markDone} onSnooze={snooze} />
)}
{upcoming.length > 0 && (
<TaskGroup label="UPCOMING" tasks={upcoming} onDone={markDone} onSnooze={snooze} />
)}
{completed.length > 0 && (
<TaskGroup label="COMPLETED" tasks={completed} dim />
)}
{tasks.length === 0 && (
<p className={styles.empty}>No tasks yet create one to stay on track.</p>
)}
</div>
);
}
interface Task {
id: string;
label: string;
dueAt?: string;
group: 'today' | 'upcoming' | 'completed';
isAIGenerated?: boolean;
}
function TaskGroup({
label, tasks, dim = false, onDone, onSnooze,
}: {
label: string;
tasks: Task[];
dim?: boolean;
onDone?: (id: string) => void;
onSnooze?: (id: string) => void;
}) {
return (
<motion.div
className={styles.group}
initial={{ opacity: 0 }}
animate={{ opacity: dim ? 0.55 : 1 }}
transition={{ duration: 0.3 }}
>
<h3 className={styles.groupLabel}>{label}</h3>
{tasks.map((task, i) => (
<motion.div
key={task.id}
className={styles.taskRow}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<span className={dim ? styles.checkDone : styles.checkOpen}>
{dim ? '✓' : '○'}
</span>
<div className={styles.taskContent}>
<span className={styles.taskLabel}>{task.label}</span>
{task.dueAt && (
<span className={styles.taskDue}>{task.dueAt}</span>
)}
{task.isAIGenerated && (
<span className={styles.aiChip}> AI</span>
)}
</div>
{!dim && onDone && onSnooze && (
<div className={styles.taskActions}>
<button className="btn-ghost" onClick={() => onDone(task.id)}>Done</button>
<button className="btn-ghost" onClick={() => onSnooze(task.id)}>Snooze</button>
</div>
)}
</motion.div>
))}
</motion.div>
);
}