Skip to content
Open
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
51 changes: 51 additions & 0 deletions app/expert-finder/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { ExpertSearchListItem } from '@/types/expertFinder';
import type { AuthorProfile } from '@/types/authorProfile';

/** Default max length for picker / list item titles */
export const SEARCH_DISPLAY_DEFAULT_MAX = 80;
/** Max length for library table name column */
export const SEARCH_DISPLAY_TABLE_MAX = 60;

export interface GetSearchDisplayTextOptions {
maxLength?: number;
/** Shown when name and query are both empty */
emptyLabel?: string;
}

/**
* Display string for an expert search (name, else query), truncated with ellipsis.
*/
export function getSearchDisplayText(
search: ExpertSearchListItem,
options?: GetSearchDisplayTextOptions
): string {
const maxLength = options?.maxLength ?? SEARCH_DISPLAY_DEFAULT_MAX;
const emptyLabel = options?.emptyLabel ?? 'Untitled search';
const text = (search.name || search.query || '').trim();
if (!text) return emptyLabel;
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
}

/** Library table / mobile card: shorter truncate, em dash when empty */
export function getSearchTableDisplayText(search: ExpertSearchListItem): string {
return getSearchDisplayText(search, {
maxLength: SEARCH_DISPLAY_TABLE_MAX,
emptyLabel: '—',
});
}

/**
* Short author label for compact UI, e.g. "Nick T." from first name + last initial.
*/
export function getShortAuthorName(author: AuthorProfile | null | undefined): string {
if (!author) return '';
const first = author.firstName?.trim();
const last = author.lastName?.trim();
if (first && last) return `${first} ${last.charAt(0)}.`;
if (author.fullName?.trim()) {
const parts = author.fullName.trim().split(/\s+/);
if (parts.length >= 2) return `${parts[0]} ${parts[parts.length - 1].charAt(0)}.`;

Check warning on line 47 in app/expert-finder/lib/utils.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.at(…)` over `[….length - index]`.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZz9kiR0ecqDqu3gpxO9&open=AZz9kiR0ecqDqu3gpxO9&pullRequest=708
return parts[0];
}
return '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,46 +205,43 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
<h2 className="text-lg font-semibold text-gray-900 mb-[2px] mt-[2px]">
Results ({searchDetail.expertResults.length})
</h2>
<div className="flex items-center gap-2">
{selectedIndices.size > 0 && (
<>
{selectedIndices.size < searchDetail.expertResults.length && (
<Button
variant="outlined"
size="sm"
onClick={() =>
setSelectedIndices(new Set(searchDetail.expertResults.map((_, i) => i)))
}
>
Select all
</Button>
)}
{selectedIndices.size === searchDetail.expertResults.length && (
<Button
variant="outlined"
size="sm"
onClick={() => setSelectedIndices(new Set())}
>
Unselect all
</Button>
)}
<span className="text-sm text-gray-600">{selectedIndices.size} selected</span>
<Button
variant="default"
size="sm"
className="gap-2"
onClick={() => {
const experts = Array.from(selectedIndices).map(
(i) => searchDetail.expertResults[i]
);
openGenerateForExperts(experts);
}}
>
<Mail className="h-4 w-4" aria-hidden />
Generate emails
</Button>
</>
<div className="flex flex-wrap items-center gap-2">
{selectedIndices.size === searchDetail.expertResults.length ? (
<Button
variant="outlined"
size="sm"
onClick={() => setSelectedIndices(new Set())}
>
Unselect all
</Button>
) : (
<Button
variant="outlined"
size="sm"
onClick={() =>
setSelectedIndices(new Set(searchDetail.expertResults.map((_, i) => i)))
}
disabled={searchDetail.expertResults.length === 0}
>
Select all
</Button>
)}
<span className="text-sm text-gray-600">{selectedIndices.size} selected</span>
<Button
variant="default"
size="sm"
className="gap-2"
onClick={() => {
const experts = Array.from(selectedIndices).map(
(i) => searchDetail.expertResults[i]
);
openGenerateForExperts(experts);
}}
disabled={selectedIndices.size === 0}
>
<Mail className="h-4 w-4" aria-hidden />
Generate emails
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';

import Link from 'next/link';
import { SearchStatusBadge } from '@/app/expert-finder/library/components/SearchStatusBadge';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
import { Badge } from '@/components/ui/Badge';
import { formatTimestamp } from '@/utils/date';
import type { ExpertSearchResult } from '@/types/expertFinder';
Expand All @@ -11,13 +13,33 @@ interface SearchDetailHeaderProps {
}

export function SearchDetailHeader({ search }: SearchDetailHeaderProps) {
const createdBy = search.createdBy?.author;
const authorId = createdBy?.id;

return (
<>
<div className="flex flex-wrap items-start justify-start gap-4">
<Badge variant="default" size="sm">
Created {formatTimestamp(search.createdAt, false)}
</Badge>
<SearchStatusBadge status={search.status} />
{createdBy && (
<span className="text-sm text-gray-600">
Created by:{' '}
{authorId ? (
<AuthorTooltip authorId={authorId}>
<Link
href={`/author/${authorId}`}
className="font-medium text-primary-600 hover:text-primary-700 hover:underline"
>
{createdBy.fullName}
</Link>
</AuthorTooltip>
) : (
<span className="font-medium text-gray-900">{createdBy.fullName}</span>
)}
</span>
)}
</div>
{search.work && <RelatedWorkCard work={search.work} size="sm" />}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { Mail, Trash2, Send, Loader2, Save, Eye } from 'lucide-react';
import { getTemplateDisplayLabel } from '@/app/expert-finder/library/[searchId]/components/GenerateEmailModal';
import { Alert } from '@/components/ui/Alert';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
import { BaseSection } from '@/components/ui/BaseSection';
import { Breadcrumbs } from '@/components/ui/Breadcrumbs';
import { Button } from '@/components/ui/Button';
Expand All @@ -19,7 +20,9 @@ import {
usePreviewEmails,
useSendEmails,
} from '@/hooks/useExpertFinder';
import { useUser } from '@/contexts/UserContext';
import { toast } from 'react-hot-toast';
import { isValidEmail } from '@/utils/validation';

export interface OutreachDetailPageContentProps {
emailId: string;
Expand All @@ -30,6 +33,7 @@ export function OutreachDetailPageContent({
emailId,
librarySearchId,
}: OutreachDetailPageContentProps) {
const { user } = useUser();
const [{ email, isLoading, error }, refetch] = useGeneratedEmailDetail(emailId);
const [{ isLoading: isUpdating }, updateEmail] = useUpdateGeneratedEmail();
const [{ isLoading: isDeleting }, deleteEmail] = useDeleteGeneratedEmail();
Expand All @@ -42,6 +46,7 @@ export function OutreachDetailPageContent({
const [actionError, setActionError] = useState<string | null>(null);
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [replyTo, setReplyTo] = useState('');

const backHref = `/expert-finder/library/${librarySearchId}?tab=outreach`;

Expand All @@ -52,6 +57,12 @@ export function OutreachDetailPageContent({
}
}, [email?.id, email?.emailSubject, email?.emailBody]);

useEffect(() => {
if (user?.email && replyTo === '') {
setReplyTo(user.email);
}
}, [user?.email]);

const isDraft = email?.status !== 'sent';
const hasEdits =
isDraft &&
Expand Down Expand Up @@ -111,9 +122,17 @@ export function OutreachDetailPageContent({

const handleSendToExpert = async () => {
if (!emailId || !email) return;
const trimmedReplyTo = (replyTo ?? '').trim();
if (!trimmedReplyTo || !isValidEmail(trimmedReplyTo)) {
setActionError('Please enter a valid Reply To email address.');
return;
}
setActionError(null);
try {
await sendEmails({ generated_email_ids: [Number(emailId)] });
await sendEmails({
generated_email_ids: [Number(emailId)],
reply_to: trimmedReplyTo,
});
setShowSendToExpertConfirm(false);
refetch();
toast.success('Email sent to the expert.');
Expand Down Expand Up @@ -183,6 +202,23 @@ export function OutreachDetailPageContent({
</div>
</div>
)}
{email.createdBy?.author && (
<div className="mt-2 text-sm text-gray-600">
Created by:{' '}
{email.createdBy.author.id ? (
<AuthorTooltip authorId={email.createdBy.author.id}>
<Link
href={`/author/${email.createdBy.author.id}`}
className="font-medium text-primary-600 hover:text-primary-700 hover:underline"
>
{email.createdBy.author.fullName}
</Link>
</AuthorTooltip>
) : (
<span className="font-medium text-gray-900">{email.createdBy.author.fullName}</span>
)}
</div>
)}
</div>
<div className="min-w-0 flex flex-wrap items-center gap-2 justify-start md:!justify-end">
<Badge variant={email.status === 'sent' ? 'success' : 'warning'}>
Expand Down Expand Up @@ -250,6 +286,23 @@ export function OutreachDetailPageContent({
)}
</BaseSection>

{isDraft && (
<BaseSection>
<Input
label="Reply To"
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
placeholder="Email address for replies"
error={
replyTo.trim() && !isValidEmail(replyTo.trim())
? 'Please enter a valid email address'
: undefined
}
/>
</BaseSection>
)}

<BaseSection>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Email Body</label>
Expand Down
Loading