| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useState } from 'react' |
| import { |
| IconPhone, |
| IconPhoneMissed, |
| IconSparkle, |
| } from './icons' |
| import { CALL, POST_CALL } from './tokens' |
|
|
| |
|
|
| export type PostCallVariant = 'expand' | 'highlights' |
|
|
| export interface TranscriptLine { |
| who: 'user' | 'assistant' |
| text: string |
| } |
|
|
| export interface PostCallCardProps { |
| |
| |
| durationSec: number |
| |
| |
| endedAt?: number |
| |
| |
| personaName?: string |
| |
| variant?: PostCallVariant |
| |
| |
| missed?: boolean |
| |
| |
| transcript?: TranscriptLine[] |
| |
| |
| summary?: string |
| |
| |
| onResume?: () => void |
| |
| |
| onCallBack?: () => void |
| |
| |
| |
| onOpenFullTranscript?: () => void |
| } |
|
|
| |
|
|
| function formatDuration(s: number): string { |
| 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: number): string { |
| return new Date(endedAt).toLocaleTimeString(undefined, { |
| hour: 'numeric', |
| minute: '2-digit', |
| }) |
| } |
|
|
| |
|
|
| const PostCallCard: React.FC<PostCallCardProps> = ({ |
| 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) |
|
|
| |
| 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>“{summary}”</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 as TranscriptLine[]).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 |
|
|
| |
| export const postCallCardInternals = { |
| formatDuration, |
| formatClock, |
| } |
|
|