Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3100';
2
3export interface ProfileSearchResult {
4 did?: string;
5 handle: string;
6 displayName?: string;
7 headline?: string;
8 avatar?: string;
9 about?: string;
10 currentRole?: string;
11 currentCompany?: string;
12 claimed?: boolean;
13}
14
15export async function fetchProfile(handleOrDid: string) {
16 const maxRetries = 3;
17 for (let attempt = 0; attempt <= maxRetries; attempt++) {
18 const res = await fetch(`${API_URL}/api/profile/${encodeURIComponent(handleOrDid)}`, {
19 next: { revalidate: 300, tags: [`profile-${handleOrDid}`] },
20 signal: AbortSignal.timeout(10000),
21 });
22 if (res.status === 429 && attempt < maxRetries) {
23 const retryAfter = Math.min(parseInt(res.headers.get('retry-after') ?? '2', 10), 3);
24 await new Promise((r) => setTimeout(r, retryAfter * 1000));
25 continue;
26 }
27 if (!res.ok) return null;
28 return res.json();
29 }
30 return null;
31}
32
33export async function searchProfiles(query: string): Promise<ProfileSearchResult[]> {
34 if (!query.trim()) return [];
35 const res = await fetch(`${API_URL}/api/search/profiles?q=${encodeURIComponent(query)}`, {
36 cache: 'no-store',
37 });
38 if (!res.ok) return [];
39 const data = await res.json();
40 return data.profiles ?? [];
41}
42
43// --- Suggestions ---
44
45export interface SuggestionProfile {
46 did: string;
47 handle: string;
48 displayName?: string;
49 headline?: string;
50 avatarUrl?: string;
51 source: string;
52 dismissed: boolean;
53}
54
55export interface SuggestionsResponse {
56 onSifa: SuggestionProfile[];
57 notOnSifa: SuggestionProfile[];
58 cursor?: string;
59}
60
61export async function fetchSuggestions(opts?: {
62 source?: string;
63 includeDismissed?: boolean;
64 cursor?: string;
65 limit?: number;
66}): Promise<SuggestionsResponse> {
67 const params = new URLSearchParams();
68 if (opts?.source) params.set('source', opts.source);
69 if (opts?.includeDismissed) params.set('include_dismissed', 'true');
70 if (opts?.cursor) params.set('cursor', opts.cursor);
71 if (opts?.limit) params.set('limit', String(opts.limit));
72
73 const qs = params.toString();
74 const res = await fetch(`${API_URL}/api/suggestions${qs ? `?${qs}` : ''}`, {
75 credentials: 'include',
76 cache: 'no-store',
77 });
78 if (!res.ok) return { onSifa: [], notOnSifa: [] };
79 return res.json();
80}
81
82export async function fetchSuggestionCount(since?: string): Promise<number> {
83 const params = since ? `?since=${encodeURIComponent(since)}` : '';
84 const res = await fetch(`${API_URL}/api/suggestions/count${params}`, {
85 credentials: 'include',
86 cache: 'no-store',
87 });
88 if (!res.ok) return 0;
89 const data = await res.json();
90 return data.count ?? 0;
91}
92
93export async function syncSuggestions(): Promise<{
94 imported: { bluesky: number; tangled: number };
95}> {
96 const res = await fetch(`${API_URL}/api/suggestions/sync`, {
97 method: 'POST',
98 credentials: 'include',
99 });
100 if (!res.ok) throw new Error('Sync failed');
101 return res.json();
102}
103
104export async function dismissSuggestion(subjectDid: string): Promise<void> {
105 await fetch(`${API_URL}/api/suggestions/dismiss`, {
106 method: 'POST',
107 headers: { 'Content-Type': 'application/json' },
108 credentials: 'include',
109 body: JSON.stringify({ subjectDid }),
110 });
111}
112
113export async function undismissSuggestion(subjectDid: string): Promise<void> {
114 await fetch(`${API_URL}/api/suggestions/dismiss/${encodeURIComponent(subjectDid)}`, {
115 method: 'DELETE',
116 credentials: 'include',
117 });
118}
119
120export async function createInvite(subjectDid: string): Promise<string> {
121 const res = await fetch(`${API_URL}/api/invites`, {
122 method: 'POST',
123 headers: { 'Content-Type': 'application/json' },
124 credentials: 'include',
125 body: JSON.stringify({ subjectDid }),
126 });
127 if (!res.ok) throw new Error('Failed to create invite');
128 const data = await res.json();
129 return data.inviteUrl;
130}
131
132export interface StatsResponse {
133 profileCount: number;
134 avatars: string[];
135 atproto: {
136 userCount: number;
137 growthPerSecond: number;
138 timestamp: number;
139 } | null;
140}
141
142export async function fetchStats(): Promise<StatsResponse | null> {
143 try {
144 const res = await fetch(`${API_URL}/api/stats`, {
145 next: { revalidate: 900 },
146 });
147 if (!res.ok) return null;
148 return res.json();
149 } catch {
150 return null;
151 }
152}
153
154export interface FeaturedProfile {
155 did: string;
156 handle: string;
157 displayName?: string;
158 avatar?: string;
159 pronouns?: string;
160 headline?: string;
161 about?: string;
162 currentRole?: string;
163 currentCompany?: string;
164 locationCountry?: string;
165 locationRegion?: string;
166 locationCity?: string;
167 countryCode?: string;
168 location?: string;
169 website?: string;
170 openTo?: string[];
171 preferredWorkplace?: string[];
172 followersCount?: number;
173 atprotoFollowersCount?: number;
174 pdsProvider?: { name: string; host: string } | null;
175 claimed: boolean;
176 featuredDate: string;
177}
178
179export async function fetchFeaturedProfile(): Promise<FeaturedProfile | null> {
180 try {
181 const res = await fetch(`${API_URL}/api/featured-profile`, {
182 next: { revalidate: 900 },
183 });
184 if (res.status === 204 || !res.ok) return null;
185 return res.json();
186 } catch {
187 return null;
188 }
189}
190
191// --- Following (My Network) ---
192
193export interface FollowProfile {
194 did: string;
195 handle: string;
196 displayName?: string;
197 headline?: string;
198 avatarUrl?: string;
199 source: string;
200 claimed: boolean;
201 followedAt: string;
202}
203
204export interface FollowingResponse {
205 follows: FollowProfile[];
206 cursor?: string;
207}
208
209export async function fetchFollowing(opts?: {
210 source?: string;
211 cursor?: string;
212 limit?: number;
213}): Promise<FollowingResponse> {
214 const params = new URLSearchParams();
215 if (opts?.source) params.set('source', opts.source);
216 if (opts?.cursor) params.set('cursor', opts.cursor);
217 if (opts?.limit) params.set('limit', String(opts.limit));
218
219 const qs = params.toString();
220 const res = await fetch(`${API_URL}/api/following${qs ? `?${qs}` : ''}`, {
221 credentials: 'include',
222 cache: 'no-store',
223 });
224 if (!res.ok) return { follows: [] };
225 return res.json();
226}
227
228export async function followUser(subjectDid: string): Promise<boolean> {
229 const res = await fetch(`${API_URL}/api/follow`, {
230 method: 'POST',
231 headers: { 'Content-Type': 'application/json' },
232 credentials: 'include',
233 body: JSON.stringify({ subjectDid }),
234 });
235 return res.ok;
236}
237
238export async function unfollowUser(subjectDid: string): Promise<boolean> {
239 const res = await fetch(`${API_URL}/api/follow/${encodeURIComponent(subjectDid)}`, {
240 method: 'DELETE',
241 credentials: 'include',
242 });
243 return res.ok;
244}
245
246// --- Apps Registry ---
247
248export interface AppRegistryEntry {
249 id: string;
250 name: string;
251 category: string;
252 collectionPrefixes: string[];
253 scanCollections: string[];
254 urlPattern?: string;
255 color: string;
256}
257
258export async function fetchAppsRegistry(): Promise<AppRegistryEntry[]> {
259 try {
260 const res = await fetch(`${API_URL}/api/apps/registry`, {
261 next: { revalidate: 86400 },
262 });
263 if (!res.ok) return [];
264 return res.json();
265 } catch {
266 return [];
267 }
268}
269
270// --- Privacy / GDPR ---
271
272export async function requestProfileRemoval(handleOrDid: string): Promise<boolean> {
273 try {
274 const res = await fetch(`${API_URL}/api/privacy/suppress`, {
275 method: 'POST',
276 headers: { 'Content-Type': 'application/json' },
277 body: JSON.stringify({ handleOrDid }),
278 });
279 return res.ok;
280 } catch {
281 return false;
282 }
283}
284
285// --- Activity Heatmap ---
286
287export interface HeatmapDay {
288 date: string;
289 total: number;
290 apps: { appId: string; count: number }[];
291}
292
293export interface HeatmapResponse {
294 days: HeatmapDay[];
295 appTotals: { appId: string; appName: string; total: number }[];
296 thresholds: [number, number, number, number];
297}
298
299export async function fetchHeatmapData(
300 handleOrDid: string,
301 days: number,
302): Promise<HeatmapResponse | null> {
303 try {
304 const res = await fetch(
305 `${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}/heatmap?days=${days}`,
306 {
307 next: { revalidate: 900, tags: [`heatmap-${handleOrDid}`] },
308 },
309 );
310 if (!res.ok) return null;
311 return (await res.json()) as HeatmapResponse;
312 } catch {
313 return null;
314 }
315}
316
317// --- Activity Teaser ---
318
319export interface ActivityItem {
320 uri: string;
321 collection: string;
322 rkey: string;
323 record: Record<string, unknown>;
324 appId: string;
325 appName: string;
326 category: string;
327 indexedAt: string;
328}
329
330export interface ActivityTeaserResponse {
331 items: ActivityItem[];
332}
333
334export async function fetchActivityTeaser(
335 handleOrDid: string,
336): Promise<ActivityTeaserResponse | null> {
337 try {
338 const res = await fetch(`${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}/teaser`, {
339 next: { revalidate: 300, tags: [`activity-teaser-${handleOrDid}`] },
340 });
341 if (!res.ok) return null;
342 return res.json();
343 } catch {
344 return null;
345 }
346}
347
348// --- Activity Feed ---
349
350export interface ActivityFeedResponse {
351 items: ActivityItem[];
352 cursor: string | null;
353 hasMore: boolean;
354 availableCategories?: string[];
355}
356
357export async function fetchActivityFeed(
358 handleOrDid: string,
359 opts?: { category?: string; limit?: number; cursor?: string },
360): Promise<ActivityFeedResponse | null> {
361 try {
362 const params = new URLSearchParams();
363 if (opts?.category) params.set('category', opts.category);
364 if (opts?.limit) params.set('limit', String(opts.limit));
365 if (opts?.cursor) params.set('cursor', opts.cursor);
366 const qs = params.toString();
367 const res = await fetch(
368 `${API_URL}/api/activity/${encodeURIComponent(handleOrDid)}${qs ? `?${qs}` : ''}`,
369 { cache: 'no-store' },
370 );
371 if (!res.ok) return null;
372 return res.json();
373 } catch {
374 return null;
375 }
376}
377
378// --- Activity Visibility ---
379
380export async function updateActivityVisibility(appId: string, visible: boolean): Promise<boolean> {
381 const res = await fetch(`${API_URL}/api/profile/activity-visibility`, {
382 method: 'PUT',
383 headers: { 'Content-Type': 'application/json' },
384 credentials: 'include',
385 body: JSON.stringify({ appId, visible }),
386 });
387 return res.ok;
388}
389
390export interface MeetingEntry {
391 subjectDid: string;
392 meetingToken: string;
393 createdAt: string;
394 note: string | null;
395 eventContext: Array<{ slug: string; name: string; bothRsvped: boolean }>;
396}
397
398export async function fetchMeetings(): Promise<MeetingEntry[]> {
399 const res = await fetch(`${API_URL}/api/meet/list`, {
400 credentials: 'include',
401 });
402 if (!res.ok) return [];
403 const data = (await res.json()) as { meetings: MeetingEntry[] };
404 return data.meetings;
405}