ConversationCard.tsx7.3 KBView on GitHub /**
* ConversationCard
*
* A single conversation rendered as a card (for the `cardList` canvas type).
* All data comes off a HydratedConversation. The whole card is a click target
* that opens the conversation (selectedArtifact { kind: 'conversation' }).
*
* Anatomy (see docs/conversation-index-and-card-lists.md §3.2 step 6):
* Header: [company icon] conversation.name ★(if starred) ←→ formatDealValue(dealValue)
* Top row: labelled display fields — stage: · next step: · last contact:
* stage renders the SAME StatusBadgeEditor the CRM ConversationItem uses
* (with the canvas's status enumOptions); dates use RelativeDateBadge.
* Tasks: each due (todo) task as an agenda-style row — [ ] task description
*/
import type { HydratedConversation, CrmFieldEnumOption } from '@/modules/crm/types';
import { formatDealValue } from '@/modules/crm/utils';
import { StatusBadgeEditor } from '@/modules/crm/components/ConversationCellComponents';
import { useOptimisticConversationActions } from '@/modules/crm/hooks/use-optimistic-conversation-actions';
import { TaskLine } from '@/modules/userTasks/components/TaskLine';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { useCedarStore } from '@/modules/store';
import { Building2, Check, Star, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ConversationCardProps {
conversation: HydratedConversation;
onClick: () => void;
/** The canvas's status enumOptions (colours/icons) — same source ConversationItem uses. */
statusOptions: CrmFieldEnumOption[];
/**
* When provided, a remove "×" is shown on the card (manual pinned-list curation).
* Fires without triggering the card's open action.
*/
onRemove?: () => void;
className?: string;
}
export function ConversationCard({
conversation,
onClick,
statusOptions,
onRemove,
className,
}: ConversationCardProps) {
const conv = conversation.conversation;
const { optimisticUpdateConversation } = useOptimisticConversationActions();
// Demo mode blurs sensitive figures (deal size / ACV) for screen-sharing.
const isDemoMode = useCedarStore((state) => state.isDemoMode);
const status = conv.status;
const logoUrl = conversation.company?.logoUrl;
const todoTasks = (conversation.userTasks ?? []).filter((t) => t.status === 'todo');
// Top-row display fields — hardcoded to stage / next step / last contact.
const topFields = status || conv.nextStepDate || conv.lastContactedAt;
return (
<div className={cn('group relative', className)}>
{onRemove && (
<span
role="button"
tabIndex={0}
aria-label="Remove from list"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onRemove();
}
}}
className={cn(
'absolute right-2 top-2 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full',
'bg-muted/70 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground',
'group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<X className="h-3 w-3" />
</span>
)}
<button
type="button"
onClick={onClick}
className={cn(
'flex w-full cursor-pointer flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3 text-left',
// hover:bg-accent, NOT a translucent bg-muted/40: the hover colour REPLACES the card's
// own `bg-card`, so a translucent one makes the whole card go see-through wherever the
// page behind it isn't flat — on the /agent hero it reads as the card fading out.
'transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{/* Header: company icon + name (+ star) ←→ ACV */}
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="" className="h-4 w-4 shrink-0 rounded-sm object-contain" />
) : (
<Building2 className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="truncate text-sm font-semibold text-foreground">
{conv.name || 'Untitled'}
</span>
{conv.important && (
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
<span
className={cn(
'shrink-0 text-sm font-semibold text-foreground transition-all',
isDemoMode && 'select-none blur-[2px]',
)}
title={isDemoMode ? 'Hidden in demo mode' : undefined}
>
{formatDealValue(conv.dealValue)}
</span>
</div>
{/* Top row — labelled display fields (SAME badge components as the CRM canvas) */}
{topFields && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{status && (
<span
className="inline-flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
stage:
<StatusBadgeEditor
value={status}
options={statusOptions}
onSelect={(v) => optimisticUpdateConversation(conv.id, { status: v || null })}
columnId="status"
placeholder="Status..."
/>
</span>
)}
{conv.nextStepDate && (
<span className="inline-flex items-center gap-1">
next step:
<RelativeDateBadge date={conv.nextStepDate} colorType="scheduled" />
</span>
)}
{conv.lastContactedAt && (
<span className="inline-flex items-center gap-1">
last contact:
<RelativeDateBadge date={conv.lastContactedAt} colorType="history" />
</span>
)}
</div>
)}
{/* Due tasks — agenda-style [ ] checkbox + description (display only); or a
caught-up state when this deal has nothing pending. */}
{todoTasks.length > 0 ? (
<div className="flex flex-col gap-1.5 pt-0.5">
{todoTasks.map((task) => (
<div key=[redacted] className="flex items-start gap-2">
<span className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded-sm border border-muted-foreground/50" />
<TaskLine description={task.description} clamp className="text-foreground/90" />
</div>
))}
</div>
) : (
<div className="flex items-center gap-1.5 pt-0.5 text-sm text-emerald-600 dark:text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" />
<span>No due tasks</span>
</div>
)}
</button>
</div>
);
}