HomePilot / frontend /src /ui /phone /PostCallCard.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
10.6 kB
/**
* PostCallCard — inline "we just talked" record rendered directly
* in the chat thread, NOT as a modal. Product direction from the
* engineering handoff:
*
* "You are building a conversational / companion experience,
* not a compliance tool. → Use inline card + expandable
* transcript."
*
* Three variants covering the full (variant × missed) matrix from
* the handoff's § 3:
*
* expand — default. Duration + time + [Resume call] +
* [View transcript ▾]. Tapping the latter expands
* the transcript INLINE inside the same card (no
* modal). Keeps the call record as an event in
* the conversation the user can glance at later.
* highlights — same shell, but the body is a short italic
* "Vesper remembers…" summary + a [Full transcript]
* link. For sessions where the call was long and
* the full transcript is too long to inline.
* missed — red tint, phone-slash icon, single [Call back]
* CTA. Set via ``missed`` prop (orthogonal to
* variant; a missed call is always rendered as the
* missed variant, others ignored).
*
* This component replaces the prior enterprise-neutral
* CallMemoryCard. The enterprise form (flat surface, \"Voice session
* completed\" copy) is preserved as a setting-free default by
* passing only durationSec + endedAt; richer props turn on the
* conversational surface.
*/
import React, { useState } from 'react';
import { IconPhone, IconPhoneMissed, IconSparkle, } from './icons';
import { CALL, POST_CALL } from './tokens';
// ── Helpers ──────────────────────────────────────────────────────
function formatDuration(s) {
if (s < 60)
return `${s}s`;
const m = Math.floor(s / 60);
const r = s % 60;
if (r === 0)
return m < 60 ? `${m} min` : `${Math.floor(m / 60)}h ${m % 60}m`;
return `${m} min ${r}s`;
}
function formatClock(endedAt) {
return new Date(endedAt).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
}
// ── Component ────────────────────────────────────────────────────
const PostCallCard = ({ durationSec, endedAt, personaName = 'Assistant', variant = 'expand', missed = false, transcript, summary, onResume, onCallBack, onOpenFullTranscript, }) => {
const [expanded, setExpanded] = useState(false);
const clock = endedAt ? formatClock(endedAt) : '';
const dur = formatDuration(durationSec);
const accent = missed ? CALL.danger : CALL.rose;
const hasTranscript = !!(transcript && transcript.length > 0);
// Top row — icon, title, sub.
const subtitle = missed
? clock
? `${clock} · tap to call back`
: 'tap to call back'
: clock
? `${dur} · ${clock}`
: dur;
const titleText = missed
? `Missed call · ${personaName}`
: `Call with ${personaName}`;
return (<div role="note" aria-label={missed ? `Missed call from ${personaName}` : `Call with ${personaName}`} className="hp-fade-in" style={{
width: POST_CALL.cardWidth,
maxWidth: '100%',
borderRadius: POST_CALL.cardRadius,
overflow: 'hidden',
background: missed
? `linear-gradient(180deg, color-mix(in oklch, ${accent} 12%, transparent) 0%, rgba(245,236,255,0.03) 100%)`
: 'linear-gradient(180deg, rgba(245,236,255,0.06) 0%, rgba(245,236,255,0.03) 100%)',
border: missed
? `0.5px solid color-mix(in oklch, ${accent} 35%, transparent)`
: `0.5px solid ${CALL.line}`,
fontFamily: CALL.font,
color: CALL.ink,
}}>
{/* Header row — icon + title + time */}
<div style={{
padding: POST_CALL.rowPadding,
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div aria-hidden="true" style={{
width: POST_CALL.avatarSize,
height: POST_CALL.avatarSize,
borderRadius: '50%',
background: `color-mix(in oklch, ${accent} 20%, transparent)`,
border: `0.5px solid color-mix(in oklch, ${accent} 40%, transparent)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: accent,
flexShrink: 0,
}}>
{missed ? (<IconPhoneMissed size={14} color={accent}/>) : (<IconPhone size={14} color={accent}/>)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
letterSpacing: -0.1,
color: missed ? accent : CALL.ink,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{titleText}
</div>
<div style={{
fontSize: 11,
color: CALL.dim,
fontVariantNumeric: 'tabular-nums',
}}>
{subtitle}
</div>
</div>
</div>
{/* Highlights body — italic summary line (only when variant is
'highlights' and we're not in the missed state). */}
{variant === 'highlights' && !missed && summary ? (<div style={{
padding: '4px 14px 12px',
fontFamily: CALL.display,
fontSize: 15,
lineHeight: 1.4,
color: 'rgba(245,236,255,0.82)',
fontStyle: 'italic',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 5,
marginBottom: 4,
fontFamily: CALL.font,
fontStyle: 'normal',
fontSize: 9,
color: 'oklch(0.80 0.15 340)',
letterSpacing: 1.4,
textTransform: 'uppercase',
}}>
<IconSparkle size={9} color="oklch(0.80 0.15 340)"/>
{personaName} remembers
</div>
<span>&ldquo;{summary}&rdquo;</span>
</div>) : null}
{/* Expanded transcript — inline, NOT a modal. Renders only for
variant='expand' when the user has clicked the expand button
and a transcript was provided. */}
{variant === 'expand' && expanded && !missed && hasTranscript ? (<div style={{
padding: '2px 14px 12px',
borderTop: `0.5px solid ${CALL.line}`,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
<div style={{
fontSize: 9,
letterSpacing: 1.4,
textTransform: 'uppercase',
color: 'rgba(245,236,255,0.4)',
paddingTop: 10,
}}>
Transcript
</div>
{transcript.map((line, i) => (<div key={i} style={{ fontSize: 12, lineHeight: 1.45 }}>
<span style={{
color: line.who === 'user'
? 'oklch(0.80 0.15 50)'
: 'oklch(0.80 0.15 340)',
fontWeight: 600,
marginRight: 6,
}}>
{line.who === 'user' ? 'You' : personaName}
</span>
<span style={{ color: 'rgba(245,236,255,0.85)' }}>
{line.text}
</span>
</div>))}
</div>) : null}
{/* CTA row */}
<div style={{
display: 'flex',
gap: 6,
padding: '8px 10px 10px',
borderTop: variant === 'expand' && expanded && !missed && hasTranscript
? 'none'
: `0.5px solid ${CALL.line}`,
}}>
<button type="button" onClick={missed ? (onCallBack ?? onResume) : onResume} disabled={!(missed ? (onCallBack ?? onResume) : onResume)} aria-label={missed ? 'Call back' : 'Resume call'} style={{
flex: 1,
height: POST_CALL.ctaHeight,
borderRadius: POST_CALL.ctaRadius,
cursor: 'pointer',
background: accent,
color: '#0c0810',
border: 'none',
fontFamily: CALL.font,
fontSize: 12,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}}>
<IconPhone size={12} color="#0c0810"/>
{missed ? 'Call back' : 'Resume call'}
</button>
{/* Secondary CTA — only the non-missed variants have one */}
{!missed && variant === 'highlights' && onOpenFullTranscript ? (<button type="button" onClick={onOpenFullTranscript} aria-label="Open full transcript" style={{
flex: 1,
height: POST_CALL.ctaHeight,
borderRadius: POST_CALL.ctaRadius,
cursor: 'pointer',
background: 'rgba(245,236,255,0.06)',
color: CALL.ink,
border: `0.5px solid ${CALL.line}`,
fontFamily: CALL.font,
fontSize: 12,
fontWeight: 500,
}}>
Full transcript
</button>) : null}
{!missed && variant === 'expand' && hasTranscript ? (<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} aria-label={expanded ? 'Collapse transcript' : 'View transcript'} style={{
flex: 1,
height: POST_CALL.ctaHeight,
borderRadius: POST_CALL.ctaRadius,
cursor: 'pointer',
background: 'rgba(245,236,255,0.06)',
color: CALL.ink,
border: `0.5px solid ${CALL.line}`,
fontFamily: CALL.font,
fontSize: 12,
fontWeight: 500,
}}>
{expanded ? 'Collapse ▴' : 'View transcript ▾'}
</button>) : null}
</div>
</div>);
};
export default PostCallCard;
// Test hooks — exposed for unit tests without widening the public API.
export const postCallCardInternals = {
formatDuration,
formatClock,
};