Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 233 lines 6.6 kB view raw
1import type { LocationValue } from '@/lib/types'; 2import { formatLocation } from '@/lib/location-utils'; 3 4interface ProfilePosition { 5 company?: string; 6 title?: string; 7 startedAt?: string; 8 endedAt?: string; 9 description?: string; 10} 11 12interface ProfileEducation { 13 institution?: string; 14 degree?: string; 15 fieldOfStudy?: string; 16} 17 18interface ProfileSkill { 19 name?: string; 20} 21 22interface ProfileCertification { 23 name?: string; 24 issuingOrg?: string; 25 credentialUrl?: string; 26} 27 28interface ProfileVolunteering { 29 organization?: string; 30 role?: string; 31 startDate?: string; 32 endDate?: string; 33} 34 35interface ProfileHonor { 36 title?: string; 37} 38 39interface ProfileLanguage { 40 language?: string; 41} 42 43interface VerifiedAccount { 44 platform: string; 45 identifier: string; 46 url?: string; 47} 48 49interface ProfileData { 50 handle: string; 51 displayName?: string; 52 headline?: string; 53 about?: string; 54 avatar?: string; 55 location?: LocationValue | null; 56 website?: string; 57 positions?: ProfilePosition[]; 58 education?: ProfileEducation[]; 59 skills?: ProfileSkill[]; 60 certifications?: ProfileCertification[]; 61 volunteering?: ProfileVolunteering[]; 62 honors?: ProfileHonor[]; 63 languages?: ProfileLanguage[]; 64 verifiedAccounts?: VerifiedAccount[]; 65} 66 67type Sanitizer = (input: string) => string; 68 69const identity: Sanitizer = (input: string) => input; 70 71export function buildPersonJsonLd(profile: ProfileData, sanitizer: Sanitizer = identity) { 72 const s = sanitizer; 73 const currentPosition = profile.positions?.find((p) => !p.endedAt); 74 75 // Collect sameAs URLs from verified accounts and website 76 const sameAs: string[] = []; 77 if (profile.website) { 78 const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`; 79 sameAs.push(url); 80 } 81 if (profile.verifiedAccounts) { 82 for (const account of profile.verifiedAccounts) { 83 if (account.url) sameAs.push(account.url); 84 } 85 } 86 87 const hasCredential = [ 88 ...(profile.education ?? []) 89 .filter((e) => e.degree) 90 .map((e) => ({ 91 '@type': 'EducationalOccupationalCredential' as const, 92 credentialCategory: 'degree' as const, 93 name: [e.degree, e.fieldOfStudy].filter(Boolean).join(' '), 94 ...(e.institution && { 95 recognizedBy: { 96 '@type': 'EducationalOrganization' as const, 97 name: s(e.institution), 98 }, 99 }), 100 })), 101 ...(profile.certifications ?? []) 102 .filter((c) => c.name) 103 .map((c) => ({ 104 '@type': 'EducationalOccupationalCredential' as const, 105 name: s(c.name!), 106 ...(c.issuingOrg && { 107 recognizedBy: { 108 '@type': 'Organization' as const, 109 name: s(c.issuingOrg), 110 }, 111 }), 112 ...(c.credentialUrl && { url: c.credentialUrl }), 113 })), 114 ]; 115 116 return { 117 '@context': 'https://schema.org', 118 '@type': 'Person', 119 name: s(profile.displayName ?? profile.handle), 120 jobTitle: profile.headline 121 ? s(profile.headline) 122 : currentPosition?.title 123 ? s(currentPosition.title) 124 : undefined, 125 description: profile.about ? s(profile.about) : undefined, 126 url: `https://sifa.id/p/${profile.handle}`, 127 image: profile.avatar ?? undefined, 128 ...(profile.location && { 129 homeLocation: { 130 '@type': 'Place', 131 name: s(formatLocation(profile.location)), 132 ...(profile.location.countryCode && { 133 address: { 134 '@type': 'PostalAddress', 135 addressCountry: profile.location.countryCode, 136 }, 137 }), 138 }, 139 }), 140 ...(profile.positions?.length && { 141 worksFor: profile.positions 142 .filter((p) => p.company) 143 .map((p) => ({ 144 '@type': 'Organization' as const, 145 name: s(p.company!), 146 ...(p.title && { 147 member: { 148 '@type': 'OrganizationRole' as const, 149 roleName: s(p.title), 150 ...(p.startedAt && { startDate: p.startedAt }), 151 ...(p.endedAt && { endDate: p.endedAt }), 152 }, 153 }), 154 })), 155 }), 156 ...(profile.education?.length && { 157 alumniOf: profile.education 158 .filter((e) => e.institution) 159 .map((e) => ({ 160 '@type': 'EducationalOrganization' as const, 161 name: s(e.institution!), 162 })), 163 }), 164 ...(hasCredential.length > 0 && { hasCredential }), 165 ...(profile.volunteering?.length && { 166 memberOf: profile.volunteering 167 .filter((v) => v.organization) 168 .map((v) => ({ 169 '@type': 'OrganizationRole' as const, 170 memberOf: { 171 '@type': 'Organization' as const, 172 name: s(v.organization!), 173 }, 174 ...(v.role && { roleName: s(v.role) }), 175 ...(v.startDate && { startDate: v.startDate }), 176 ...(v.endDate && { endDate: v.endDate }), 177 })), 178 }), 179 ...(() => { 180 const awards = (profile.honors ?? []).filter((h) => h.title).map((h) => s(h.title!)); 181 return awards.length > 0 ? { award: awards } : {}; 182 })(), 183 ...(profile.languages?.length && { 184 knowsLanguage: profile.languages.filter((l) => l.language).map((l) => l.language!), 185 }), 186 ...(profile.skills?.length && { 187 knowsAbout: profile.skills.map((sk) => (sk.name ? s(sk.name) : undefined)), 188 }), 189 ...(sameAs.length > 0 && { sameAs }), 190 }; 191} 192 193export function buildProfilePageJsonLd(profile: ProfileData, sanitizer: Sanitizer = identity) { 194 const person = buildPersonJsonLd(profile, sanitizer); 195 const { '@context': _, ...personWithoutContext } = person; 196 197 return { 198 '@context': 'https://schema.org', 199 '@type': 'ProfilePage' as const, 200 url: `https://sifa.id/p/${profile.handle}`, 201 mainEntity: personWithoutContext, 202 }; 203} 204 205/** 206 * Generate a meta description from profile data. 207 * Falls back gracefully when data is incomplete. 208 */ 209export function buildMetaDescription(profile: ProfileData): string { 210 const parts: string[] = []; 211 212 if (profile.headline) { 213 parts.push(profile.headline); 214 } 215 216 const currentPosition = profile.positions?.find((p) => !p.endedAt); 217 if (currentPosition) { 218 const positionParts: string[] = []; 219 if (currentPosition.title) positionParts.push(currentPosition.title); 220 if (currentPosition.company) positionParts.push(`at ${currentPosition.company}`); 221 if (positionParts.length > 0) parts.push(positionParts.join(' ')); 222 } 223 224 if (profile.location) { 225 parts.push(formatLocation(profile.location)); 226 } 227 228 if (parts.length === 0) { 229 return `${profile.displayName ?? profile.handle} on Sifa`; 230 } 231 232 return parts.join(' · '); 233}