diff --git a/app/author/[id]/components/AuthorProfile.tsx b/app/author/[id]/components/AuthorProfile.tsx index a68a5efd8..faaf2f60e 100644 --- a/app/author/[id]/components/AuthorProfile.tsx +++ b/app/author/[id]/components/AuthorProfile.tsx @@ -230,10 +230,12 @@ const AuthorProfile: React.FC = ({ author, refetchAuthorInfo /> } - href={author.orcidId} + href={author.isOrcidConnected ? author.orcidId : null} label="ORCID" className={ - author.orcidId ? '[&>svg]:text-[#A6CE39] [&>svg]:hover:text-[#82A629] px-0' : 'px-0' + author.isOrcidConnected + ? '[&>svg]:text-orcid-500 [&>svg]:hover:text-orcid-600 px-0' + : 'px-0' } />
- {Array.from({ length: 4 }).map((_, i) => ( + {Array.from({ length: 5 }).map((_, i) => (
@@ -30,7 +30,7 @@ export function ModerationSkeleton() { ))}
- {Array.from({ length: 4 }).map((_, i) => ( + {Array.from({ length: 5 }).map((_, i) => (
@@ -241,6 +241,10 @@ export default function Moderation({ userId, authorId, refetchAuthorInfo }: Mode Suspended? {userDetails.isSuspended ? 'Yes' : 'No'}
+
+ ORCID Connected? + {userDetails.isOrcidConnected ? 'Yes' : 'No'} +
@@ -275,6 +279,10 @@ export default function Moderation({ userId, authorId, refetchAuthorInfo }: Mode Verified status: {userDetails.verification?.status || 'N/A'}
+
+ ORCID Email: + {userDetails.orcidVerifiedEduEmail || 'N/A'} +
diff --git a/app/author/[id]/page.tsx b/app/author/[id]/page.tsx index 6623aa349..c8d715fad 100644 --- a/app/author/[id]/page.tsx +++ b/app/author/[id]/page.tsx @@ -18,7 +18,8 @@ import AuthorProfile from './components/AuthorProfile'; import { useAuthorPublications } from '@/hooks/usePublications'; import { transformPublicationToFeedEntry } from '@/types/publication'; import PinnedFundraise from './components/PinnedFundraise'; - +import { OrcidSyncBanner } from '@/components/Orcid/OrcidSyncBanner'; +import { useOrcidCallback } from '@/components/Orcid/lib/hooks/useOrcidCallback'; function toNumberOrNull(value: any): number | null { if (value === '' || value === null || value === undefined) return null; const num = Number(value); @@ -255,8 +256,8 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st const { isLoading: isUserLoading, error: userError } = useUser(); const authorId = toNumberOrNull(resolvedParams.id); const [{ author: user, isLoading, error }, refetchAuthorInfo] = useAuthorInfo(authorId); - const { user: currentUser } = useUser(); - // Determine if current user is a hub editor + const { user: currentUser, refreshUser } = useUser(); + useOrcidCallback({ onSuccess: refreshUser }); const isHubEditor = !!currentUser?.authorProfile?.isHubEditor; const [{ achievements, isLoading: isAchievementsLoading, error: achievementsError }] = useAuthorAchievements(authorId); @@ -291,8 +292,14 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st return ; } + const isOwnProfile = Boolean( + currentUser?.authorProfile?.id && user.authorProfile.id === currentUser.authorProfile.id + ); + const isOrcidConnected = Boolean(currentUser?.authorProfile?.isOrcidConnected); + return ( <> + diff --git a/app/styles/colors.ts b/app/styles/colors.ts index 3c3bd9b95..91b86ec29 100644 --- a/app/styles/colors.ts +++ b/app/styles/colors.ts @@ -39,4 +39,11 @@ export const colors = { 900: '#1e3a8a', 950: '#172554', }, + orcid: { + 50: '#F5FAEB', // Pale lemon background + 100: '#E3F3C1', // Light green + 200: '#DCEEC4', // Border green + 500: '#A6CE39', // ORCID brand green + 600: '#82A629', // Darker green for hover + }, }; diff --git a/components/Orcid/OrcidConnectButton.tsx b/components/Orcid/OrcidConnectButton.tsx new file mode 100644 index 000000000..464245b32 --- /dev/null +++ b/components/Orcid/OrcidConnectButton.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faOrcid } from '@fortawesome/free-brands-svg-icons'; +import { Loader2 } from 'lucide-react'; +import { Button, ButtonProps } from '@/components/ui/Button'; +import { useConnectOrcid } from '@/components/Orcid/lib/hooks/useConnectOrcid'; + +interface OrcidConnectButtonProps { + readonly variant?: ButtonProps['variant']; + readonly size?: ButtonProps['size']; + readonly className?: string; + readonly showIcon?: boolean; +} + +export function OrcidConnectButton({ + variant = 'outlined', + size = 'default', + className, + showIcon = true, +}: OrcidConnectButtonProps) { + const { connect, isConnecting } = useConnectOrcid(); + + return ( + + ); +} diff --git a/components/Orcid/OrcidSyncBanner.tsx b/components/Orcid/OrcidSyncBanner.tsx new file mode 100644 index 000000000..cb5e6b225 --- /dev/null +++ b/components/Orcid/OrcidSyncBanner.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { X } from 'lucide-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faOrcid } from '@fortawesome/free-brands-svg-icons'; +import { OrcidConnectButton } from '@/components/Orcid/OrcidConnectButton'; +import { useDismissableFeature } from '@/hooks/useDismissableFeature'; +import { Button } from '@/components/ui/Button'; + +const FEATURE_NAME = 'orcid_sync_banner'; + +interface OrcidSyncBannerProps { + readonly isOwnProfile: boolean; + readonly isOrcidConnected: boolean; +} + +export function OrcidSyncBanner({ isOwnProfile, isOrcidConnected }: OrcidSyncBannerProps) { + const { isDismissed, dismissFeature, dismissStatus } = useDismissableFeature(FEATURE_NAME); + + if (!isOwnProfile || isOrcidConnected || dismissStatus !== 'checked' || isDismissed) { + return null; + } + + return ( +
+ + +
+
+
+ +
+ +
+

Connect your ORCID account

+

+ Sync your ORCID publications with your ResearchHub profile +

+
+
+ + +
+
+ ); +} diff --git a/components/Orcid/lib/hooks/useConnectOrcid.ts b/components/Orcid/lib/hooks/useConnectOrcid.ts new file mode 100644 index 000000000..cb78d0d06 --- /dev/null +++ b/components/Orcid/lib/hooks/useConnectOrcid.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { connectOrcidAccount } from '@/components/Orcid/lib/services/orcid.service'; +import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; +import toast from 'react-hot-toast'; + +export function useConnectOrcid() { + const [isConnecting, setIsConnecting] = useState(false); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const connect = useCallback(async () => { + const url = new URL(pathname, globalThis.location.origin); + const query = searchParams.toString(); + if (query) { + url.search = query; + } + + setIsConnecting(true); + const result = await connectOrcidAccount(url.toString()); + + if (result.success) { + globalThis.location.href = result.authUrl; + } else { + toast.error(extractApiErrorMessage(result.error, 'Unable to connect to ORCID.')); + setIsConnecting(false); + } + }, [pathname, searchParams]); + + return { connect, isConnecting }; +} diff --git a/components/Orcid/lib/hooks/useOrcidCallback.ts b/components/Orcid/lib/hooks/useOrcidCallback.ts new file mode 100644 index 000000000..37cf27a52 --- /dev/null +++ b/components/Orcid/lib/hooks/useOrcidCallback.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useSearchParams, usePathname, useRouter } from 'next/navigation'; +import toast from 'react-hot-toast'; + +const ORCID_MESSAGES = { + success: 'ORCID connected', + error: 'Unable to connect to ORCID', + already_linked: 'This ORCID ID is already linked', +} as const; + +interface UseOrcidCallbackOptions { + readonly onSuccess?: () => void; +} + +export function useOrcidCallback({ onSuccess }: UseOrcidCallbackOptions = {}) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const orcidSuccess = searchParams.get('orcid_connected') === 'true'; + const orcidError = searchParams.get('orcid_error'); + + useEffect(() => { + if (!orcidSuccess && !orcidError) return; + + if (orcidSuccess) { + toast.success(ORCID_MESSAGES.success, { id: 'orcid-success' }); + onSuccess?.(); + } else if (orcidError) { + const errorMessage = + ORCID_MESSAGES[orcidError as keyof typeof ORCID_MESSAGES] || decodeURIComponent(orcidError); + toast.error(errorMessage, { id: 'orcid-error' }); + } + + const params = new URLSearchParams(searchParams.toString()); + params.delete('orcid_connected'); + params.delete('orcid_error'); + const query = params.toString(); + const url = query ? `${pathname}?${query}` : pathname; + router.replace(url); + }, [orcidSuccess, orcidError, pathname, searchParams, router, onSuccess]); +} diff --git a/components/Orcid/lib/services/orcid.service.ts b/components/Orcid/lib/services/orcid.service.ts new file mode 100644 index 000000000..264b0a9cf --- /dev/null +++ b/components/Orcid/lib/services/orcid.service.ts @@ -0,0 +1,19 @@ +import { ApiClient } from '@/services/client'; + +type ConnectResult = { success: true; authUrl: string } | { success: false; error: string }; + +export async function connectOrcidAccount(returnUrl: string): Promise { + try { + const response = await ApiClient.post<{ auth_url: string }>('/api/orcid/connect/', { + return_url: returnUrl, + }); + + if (!response.auth_url) { + return { success: false, error: 'Unable to connect to ORCID.' }; + } + + return { success: true, authUrl: response.auth_url }; + } catch { + return { success: false, error: 'Unable to connect to ORCID.' }; + } +} diff --git a/components/profile/About/ProfileInformationForm/index.tsx b/components/profile/About/ProfileInformationForm/index.tsx index 8e50ed415..308e6087b 100644 --- a/components/profile/About/ProfileInformationForm/index.tsx +++ b/components/profile/About/ProfileInformationForm/index.tsx @@ -25,6 +25,7 @@ import toast from 'react-hot-toast'; import { Accordion, AccordionItem } from '@/components/ui/Accordion'; import { faUpload, faGraduationCap, faShareNodes, faUser } from '@fortawesome/pro-light-svg-icons'; import Icon from '@/components/ui/icons/Icon'; +import { OrcidConnectButton } from '@/components/Orcid/OrcidConnectButton'; interface ProfileInformationFormProps { onSubmit: (data: ProfileInformationFormValues) => void; @@ -52,20 +53,20 @@ export function ProfileInformationForm({ icon: , label: 'Google Scholar Profile URL', }, - orcid_id: { - icon: , - label: 'ORCID URL', - }, - twitter: { icon: , label: 'X (Twitter) Profile URL', }, + orcid_id: { + icon: , + label: 'ORCID URL', + }, } as const; const { user } = useUser(); const authorProfile = user?.authorProfile; + const isOrcidConnected = authorProfile?.isOrcidConnected ?? false; const methods = useForm({ resolver: zodResolver(getProfileInformationSchema({ fields })), @@ -249,6 +250,8 @@ export function ProfileInformationForm({
{(Object.keys(socialLinkMeta) as Array).map((key) => { const meta = socialLinkMeta[key]; + const isOrcid = key === 'orcid_id'; + return (
- + {isOrcid && !isOrcidConnected ? ( + + ) : ( + + )}
); })} diff --git a/components/ui/AuthorTooltip.tsx b/components/ui/AuthorTooltip.tsx index 0afa4b8ec..34c77dc80 100644 --- a/components/ui/AuthorTooltip.tsx +++ b/components/ui/AuthorTooltip.tsx @@ -10,6 +10,7 @@ import { cn } from '@/utils/styles'; import { InfoIcon } from 'lucide-react'; import { SocialIcon } from '@/components/ui/SocialIcon'; import { VerifiedBadge } from '@/components/ui/VerifiedBadge'; +import { colors } from '@/app/styles/colors'; interface AuthorTooltipProps { authorId?: number; @@ -344,12 +345,14 @@ export const AuthorTooltip: React.FC = ({ label="Google Scholar" />