Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 165 additions & 3 deletions app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
'use client';

import { useState } from 'react';
import { useCallback, useState } from 'react';
import Link from 'next/link';
import { Award, Building2, GraduationCap, Info, Mail, Pencil } from 'lucide-react';
import { useRouter } from 'next/navigation';
import {
Award,
Building2,
FileText,
GraduationCap,
Info,
Loader2,
Mail,
Pencil,
Sparkles,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/form/Checkbox';
import { Progress } from '@/components/ui/Progress';
import { Tooltip } from '@/components/ui/Tooltip';
import { cn } from '@/utils/styles';
import { formatTimestamp } from '@/utils/date';
import type { ExpertResult } from '@/types/expertFinder';
import type { ExpertResult, ProposalDraft } from '@/types/expertFinder';
import {
buildExpertSearchHref,
buildOutreachDocumentHref,
isProposalDraftActive,
isProposalDraftComplete,
outreachDocumentLabel,
proposalDraftStepProgress,
} from '@/types/expertFinder';
import { useProposalDraft } from '@/hooks/useExpertFinder';
import { NoteService } from '@/services/note.service';
import { ExpertFormModal } from './ExpertFormModal';
import { ExpertSourceLinkIcon } from './ExpertSourceLinkIcon';

Expand All @@ -26,6 +44,8 @@ interface ExpertResultCardProps {
onToggleSelect?: (index: number) => void;
onGenerateEmail?: (expert: ExpertResult) => void;
onSuccess?: () => Promise<void>;
/** Enables AI proposal drafts (grant-linked searches only). */
proposalDraftsEnabled?: boolean;
}

function empty(value: string | undefined): string {
Expand All @@ -35,6 +55,107 @@ function empty(value: string | undefined): string {
/** Show Read more when notes are longer than this */
const NOTES_READ_MORE_MIN_LENGTH = 25;

interface ProposalDraftSectionProps {
draft: ProposalDraft | null;
isStarting: boolean;
startError: string | null;
isOpeningNote: boolean;
onStart: () => void;
onOpenNote: () => void;
}

function ProposalDraftSection({
draft,
isStarting,
startError,
isOpeningNote,
onStart,
onOpenNote,
}: Readonly<ProposalDraftSectionProps>) {
if (draft && isProposalDraftActive(draft)) {
const progress = proposalDraftStepProgress(draft);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5" aria-live="polite">
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary-600" aria-hidden />
<span>Drafting proposal…</span>
<span className="ml-auto text-xs font-normal tabular-nums text-gray-500">
{progress.position}/{progress.total}
</span>
</div>
<Progress value={progress.position} max={progress.total} size="sm" className="mt-2" />
<p className="mt-1.5 text-xs text-gray-500">{progress.label}</p>
</div>
);
}

if (startError || draft?.status === 'FAILED') {
return (
<div className="flex flex-col gap-2">
<Alert variant="error" className="py-2.5 px-3">
<p>Proposal draft failed</p>
<p className="font-normal">
{startError || draft?.errorMessage || 'An error occurred while drafting the proposal.'}
</p>
</Alert>
<Button
type="button"
variant="outlined"
size="sm"
className="w-full gap-2"
onClick={onStart}
disabled={isStarting}
>
{isStarting ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
) : (
<Sparkles className="h-4 w-4 shrink-0" aria-hidden />
)}
Retry proposal draft
</Button>
</div>
);
}

if (isProposalDraftComplete(draft)) {
return (
<Button
type="button"
variant="outlined"
size="sm"
className="w-full gap-2"
onClick={onOpenNote}
disabled={isOpeningNote}
>
{isOpeningNote ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
) : (
<FileText className="h-4 w-4 shrink-0" aria-hidden />
)}
Open proposal
</Button>
);
}

return (
<Button
type="button"
variant="outlined"
size="sm"
className="w-full gap-2"
onClick={onStart}
disabled={isStarting}
>
{isStarting ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
) : (
<Sparkles className="h-4 w-4 shrink-0" aria-hidden />
)}
Draft proposal
</Button>
);
}

export function ExpertResultCard({
expert,
index,
Expand All @@ -43,8 +164,39 @@ export function ExpertResultCard({
onToggleSelect,
onGenerateEmail,
onSuccess,
proposalDraftsEnabled,
}: ExpertResultCardProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [isOpeningNote, setIsOpeningNote] = useState(false);

// Refresh the search detail once a draft settles so selection-based flows
// (e.g. proposal invitation emails) see the latest draft state.
const handleDraftSettled = useCallback(() => {
void onSuccess?.();
}, [onSuccess]);
const [{ draft, isStarting, startError }, startDraft] = useProposalDraft(
expert.proposalDraft,
handleDraftSettled
);
const showProposalDraft = Boolean(proposalDraftsEnabled) && expert.searchExpertId != null;

const handleStartDraft = useCallback(() => {
if (expert.searchExpertId != null) void startDraft(expert.searchExpertId);
}, [expert.searchExpertId, startDraft]);

const handleOpenNote = useCallback(async () => {
if (draft?.noteId == null) return;
setIsOpeningNote(true);
try {
const note = await NoteService.getNote(String(draft.noteId));
router.push(`/notebook/${note.organization.slug}/${draft.noteId}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to open the proposal note');
} finally {
setIsOpeningNote(false);
}
}, [draft?.noteId, router]);
const name = empty(expert.name);
const canEditContact = expert.expertId != null && Boolean(onSuccess);
const title = empty(expert.title);
Expand Down Expand Up @@ -286,6 +438,16 @@ export function ExpertResultCard({
</div>

<div className="mt-auto pt-4 shrink-0 flex flex-col gap-2">
{showProposalDraft ? (
<ProposalDraftSection
draft={draft}
isStarting={isStarting}
startError={startError}
isOpeningNote={isOpeningNote}
onStart={handleStartDraft}
onOpenNote={() => void handleOpenNote()}
/>
) : null}
{onGenerateEmail && (
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { Dropdown, DropdownItem } from '@/components/ui/form/Dropdown';
import { useSavedTemplates } from '@/hooks/useExpertFinder';
import { cn } from '@/utils/styles';
import type { ExpertResult } from '@/types/expertFinder';
import type { EmailTemplateKind } from '@/services/expertFinder.service';
import { isProposalDraftComplete } from '@/types/expertFinder';
import {
PROPOSAL_DRAFT_OUTREACH_TEMPLATE,
type EmailTemplateKind,
} from '@/services/expertFinder.service';

export type GenerateEmailConfirmPayload =
| { mode: 'ai'; template: string }
Expand All @@ -19,6 +23,8 @@ interface GenerateEmailModalProps {
onClose: () => void;
experts: ExpertResult[];
onConfirm: (payload: GenerateEmailConfirmPayload) => void;
/** Whether the search is linked to a grant/funding round (enables proposal invitations). */
isGrantLinked?: boolean;
}

export interface EmailTemplateOption {
Expand All @@ -28,6 +34,11 @@ export interface EmailTemplateOption {
}

export const EMAIL_TEMPLATE_OPTIONS: EmailTemplateOption[] = [
{
value: PROPOSAL_DRAFT_OUTREACH_TEMPLATE,
label: 'Proposal Invitation',
description: 'Invite them to review and edit an AI-drafted proposal for this funding round',
},
{
value: 'rfp-outreach',
label: 'Call for Proposals',
Expand Down Expand Up @@ -133,11 +144,26 @@ export function GenerateEmailModal({
onClose,
experts,
onConfirm,
isGrantLinked = false,
}: GenerateEmailModalProps) {
const [creationMode, setCreationMode] = useState<CreationMode>('template');
const [purpose, setPurpose] = useState<EmailTemplateKind>(
EMAIL_TEMPLATE_OPTIONS[0]?.value ?? 'collaboration'
// Proposal invitations are offered only on grant-linked searches, and are
// selectable only when every selected expert has a completed proposal draft.
const proposalOptionVisible = isGrantLinked;
const proposalSelectable =
proposalOptionVisible &&
experts.length > 0 &&
experts.every((expert) => isProposalDraftComplete(expert.proposalDraft));

const visibleOptions = EMAIL_TEMPLATE_OPTIONS.filter(
(option) => option.value !== PROPOSAL_DRAFT_OUTREACH_TEMPLATE || proposalOptionVisible
);
const defaultPurpose: EmailTemplateKind = proposalSelectable
? PROPOSAL_DRAFT_OUTREACH_TEMPLATE
: (visibleOptions.find((option) => option.value !== PROPOSAL_DRAFT_OUTREACH_TEMPLATE)?.value ??
'collaboration');

const [creationMode, setCreationMode] = useState<CreationMode>('template');
const [purpose, setPurpose] = useState<EmailTemplateKind>(defaultPurpose);
const [customUseCase, setCustomUseCase] = useState('');
const [savedTemplateId, setSavedTemplateId] = useState<number | null>(null);
const [purposeDropdownOpen, setPurposeDropdownOpen] = useState(false);
Expand All @@ -146,11 +172,13 @@ export function GenerateEmailModal({

useEffect(() => {
if (isOpen) {
setCreationMode('template');
setPurpose(EMAIL_TEMPLATE_OPTIONS[0]?.value ?? 'collaboration');
setCreationMode(proposalSelectable ? 'ai' : 'template');
setPurpose(defaultPurpose);
setCustomUseCase('');
setSavedTemplateId(null);
}
// Only reset when the modal opens; eligibility is fixed for the session.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);

const count = experts.length;
Expand All @@ -161,7 +189,7 @@ export function GenerateEmailModal({
const canSubmit = creationMode === 'ai' ? canSubmitAi : canSubmitFixed;

const selectedPurpose =
EMAIL_TEMPLATE_OPTIONS.find((option) => option.value === purpose) ?? EMAIL_TEMPLATE_OPTIONS[0];
visibleOptions.find((option) => option.value === purpose) ?? visibleOptions[0];

const handleSubmit = () => {
if (creationMode === 'ai') {
Expand Down Expand Up @@ -239,21 +267,34 @@ export function GenerateEmailModal({
onOpenChange={setPurposeDropdownOpen}
className="max-h-72 overflow-y-auto"
>
{EMAIL_TEMPLATE_OPTIONS.map((option) => (
<DropdownItem
key={option.value}
onClick={() => setPurpose(option.value)}
className={cn(
'items-start text-left',
purpose === option.value && 'bg-gray-100 font-medium'
)}
>
<div className="w-full text-left">
<div className="text-sm text-gray-900">{option.label}</div>
<div className="text-xs text-gray-500">{option.description}</div>
</div>
</DropdownItem>
))}
{visibleOptions.map((option) => {
const isProposalOption = option.value === PROPOSAL_DRAFT_OUTREACH_TEMPLATE;
const optionDisabled = isProposalOption && !proposalSelectable;
return (
<DropdownItem
key={option.value}
onClick={() => {
if (!optionDisabled) setPurpose(option.value);
}}
disabled={optionDisabled}
className={cn(
'items-start text-left',
purpose === option.value && 'bg-gray-100 font-medium',
optionDisabled && 'cursor-not-allowed opacity-60'
)}
>
<div className="w-full text-left">
<div className="text-sm text-gray-900">{option.label}</div>
<div className="text-xs text-gray-500">{option.description}</div>
{optionDisabled ? (
<div className="mt-0.5 text-xs text-amber-700">
Every selected expert needs a completed proposal draft
</div>
) : null}
</div>
</DropdownItem>
);
})}
</Dropdown>

{isCustom && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import Link from 'next/link';
import { Check, Loader2, X } from 'lucide-react';
import { BaseModal } from '@/components/ui/BaseModal';
import { Progress } from '@/components/ui/Progress';
import { ExpertFinderService } from '@/services/expertFinder.service';
import {
ExpertFinderService,
PROPOSAL_DRAFT_OUTREACH_TEMPLATE,
} from '@/services/expertFinder.service';
import type { ExpertResult } from '@/types/expertFinder';
import type { GenerateEmailConfirmPayload } from './GenerateEmailModal';

Expand Down Expand Up @@ -71,10 +74,14 @@ export function GenerateEmailProgressModal({

try {
if (generation.mode === 'ai') {
const isProposalInvitation = generation.template === PROPOSAL_DRAFT_OUTREACH_TEMPLATE;
await ExpertFinderService.generateEmail({
expert_search_id: Number(searchId),
expert_email: expert.email?.trim() ?? '',
template: generation.template,
...(isProposalInvitation
? { proposal_draft_id: expert.proposalDraft?.id ?? null }
: {}),
});
} else {
await ExpertFinderService.generateEmail({
Expand Down
Loading