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,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>
);
}