@mereb/app-profile 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { MeProfileQuery, ProfileByHandleQuery } from '@mereb/shared-graphql';
2
+ export { MeProfileDocument, ProfileByHandleDocument, FollowUserDocument, UnfollowUserDocument, UpdateProfileDocument } from '@mereb/shared-graphql';
3
+ export type ViewerProfile = MeProfileQuery['me'];
4
+ export type ProfileData = ProfileByHandleQuery['userByHandle'];
5
+ export type ProfilePost = NonNullable<ProfileData>['posts']['edges'][number]['node'];
6
+ export declare function parseProfileTimestamp(value?: string | null): Date | null;
7
+ export declare function formatRelativeProfileTimestamp(value?: string | null, now?: number): string;
8
+ export declare function selectProfilePosts(profile?: ProfileData | null): ProfilePost[];
@@ -0,0 +1,11 @@
1
+ type AuthControls = {
2
+ token?: string;
3
+ login?: () => Promise<void>;
4
+ logout?: () => Promise<void>;
5
+ };
6
+ type ProfileScreenProps = {
7
+ auth?: AuthControls;
8
+ handle?: string;
9
+ };
10
+ export declare function ProfileScreen({ auth, handle }: Readonly<ProfileScreenProps>): import("react").JSX.Element;
11
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@mereb/app-profile",
3
+ "version": "0.0.1",
4
+ "description": "Profile experience components and hooks for Mereb apps",
5
+ "type": "module",
6
+ "main": "src/index.tsx",
7
+ "types": "src/index.tsx",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.tsx",
11
+ "default": "./src/index.tsx"
12
+ },
13
+ "./headless": {
14
+ "types": "./src/headless.ts",
15
+ "default": "./src/headless.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "src",
20
+ "dist",
21
+ "package.json"
22
+ ],
23
+ "dependencies": {
24
+ "@mereb/shared-graphql": "^0.0.6",
25
+ "@mereb/ui-shared": "^0.0.1",
26
+ "@mereb/tokens": "^0.0.4"
27
+ },
28
+ "peerDependencies": {
29
+ "@apollo/client": ">=3.8.0",
30
+ "expo-router": ">=3.0.0",
31
+ "react": ">=18.2.0",
32
+ "react-native": ">=0.72.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "~18.2.79",
36
+ "typescript": ">=5.3.3"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit --project tsconfig.json",
40
+ "build": "tsc --project tsconfig.json"
41
+ }
42
+ }
@@ -0,0 +1,66 @@
1
+ import type {
2
+ MeProfileQuery,
3
+ ProfileByHandleQuery
4
+ } from '@mereb/shared-graphql';
5
+
6
+ export {
7
+ MeProfileDocument,
8
+ ProfileByHandleDocument,
9
+ FollowUserDocument,
10
+ UnfollowUserDocument,
11
+ UpdateProfileDocument
12
+ } from '@mereb/shared-graphql';
13
+
14
+ export type ViewerProfile = MeProfileQuery['me'];
15
+ export type ProfileData = ProfileByHandleQuery['userByHandle'];
16
+ export type ProfilePost = NonNullable<ProfileData>['posts']['edges'][number]['node'];
17
+
18
+ const MINUTE = 60 * 1000;
19
+ const HOUR = 60 * MINUTE;
20
+ const DAY = 24 * HOUR;
21
+
22
+ export function parseProfileTimestamp(value?: string | null): Date | null {
23
+ if (!value) {
24
+ return null;
25
+ }
26
+ const numeric = Number(value);
27
+ if (!Number.isNaN(numeric) && numeric > 0) {
28
+ const numericDate = new Date(numeric);
29
+ return Number.isNaN(numericDate.getTime()) ? null : numericDate;
30
+ }
31
+ const date = new Date(value);
32
+ return Number.isNaN(date.getTime()) ? null : date;
33
+ }
34
+
35
+ export function formatRelativeProfileTimestamp(value?: string | null, now: number = Date.now()): string {
36
+ const parsed = parseProfileTimestamp(value);
37
+ if (!parsed) {
38
+ return 'Just now';
39
+ }
40
+
41
+ const diff = now - parsed.getTime();
42
+ if (diff < MINUTE) {
43
+ return 'Just now';
44
+ }
45
+ if (diff < HOUR) {
46
+ const mins = Math.round(diff / MINUTE);
47
+ return `${mins} min${mins === 1 ? '' : 's'} ago`;
48
+ }
49
+ if (diff < DAY) {
50
+ const hours = Math.round(diff / HOUR);
51
+ return `${hours} hr${hours === 1 ? '' : 's'} ago`;
52
+ }
53
+
54
+ return parsed.toLocaleDateString(undefined, {
55
+ year: parsed.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
56
+ month: 'short',
57
+ day: 'numeric'
58
+ });
59
+ }
60
+
61
+ export function selectProfilePosts(profile?: ProfileData | null): ProfilePost[] {
62
+ if (!profile?.posts?.edges?.length) {
63
+ return [];
64
+ }
65
+ return profile.posts.edges.map((edge) => edge.node);
66
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,734 @@
1
+ import {useCallback, useEffect, useMemo, useState} from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Image,
5
+ RefreshControl,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ View
12
+ } from 'react-native';
13
+ import {
14
+ useFollowUserMutation,
15
+ useMeProfileQuery,
16
+ useProfileByHandleQuery,
17
+ useUnfollowUserMutation,
18
+ useUpdateProfileMutation
19
+ } from '@mereb/shared-graphql';
20
+ import {tokens} from '@mereb/tokens/native';
21
+ import {cardRecipes, placeholderAvatarRecipe} from '@mereb/ui-shared';
22
+ import {formatRelativeProfileTimestamp, selectProfilePosts} from './headless.js';
23
+
24
+ type AuthControls = {
25
+ token?: string;
26
+ login?: () => Promise<void>;
27
+ logout?: () => Promise<void>;
28
+ };
29
+
30
+ type ProfileScreenProps = {
31
+ auth?: AuthControls;
32
+ handle?: string;
33
+ };
34
+
35
+ type GraphQLErrorLike = {
36
+ message?: string;
37
+ };
38
+
39
+ function extractGraphQLErrors(
40
+ error: unknown
41
+ ): GraphQLErrorLike[] | undefined {
42
+ if (
43
+ error &&
44
+ typeof error === 'object' &&
45
+ 'graphQLErrors' in error &&
46
+ Array.isArray((error as { graphQLErrors?: unknown }).graphQLErrors)
47
+ ) {
48
+ return (error as { graphQLErrors?: GraphQLErrorLike[] }).graphQLErrors;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function PlaceholderAvatar() {
54
+ return (
55
+ <View style={styles.placeholderAvatar} accessible={false}>
56
+ <View style={styles.placeholderHead}/>
57
+ <View style={styles.placeholderShoulders}/>
58
+ </View>
59
+ );
60
+ }
61
+
62
+ export function ProfileScreen({auth, handle}: Readonly<ProfileScreenProps>) {
63
+ const token = auth?.token;
64
+ const viewingOwnProfile = !handle;
65
+ const trimmedHandle = handle?.replace(/^@/, '') ?? '';
66
+
67
+ const {
68
+ data: meData,
69
+ loading: loadingMe,
70
+ error: meError,
71
+ refetch: refetchMe
72
+ } = useMeProfileQuery({
73
+ variables: {postsLimit: 10},
74
+ skip: !viewingOwnProfile || !token,
75
+ notifyOnNetworkStatusChange: true
76
+ });
77
+
78
+ const {
79
+ data: handleData,
80
+ loading: loadingHandle,
81
+ error: handleError,
82
+ refetch: refetchHandle
83
+ } = useProfileByHandleQuery({
84
+ variables: {handle: trimmedHandle, postsLimit: 10},
85
+ skip: viewingOwnProfile || trimmedHandle.length === 0,
86
+ notifyOnNetworkStatusChange: true
87
+ });
88
+
89
+ const activeError = viewingOwnProfile ? meError : handleError;
90
+ const activeLoading = viewingOwnProfile ? loadingMe : loadingHandle;
91
+ const activeRefetch = viewingOwnProfile ? refetchMe : refetchHandle;
92
+ const profile = viewingOwnProfile ? meData?.me : handleData?.userByHandle;
93
+
94
+ const followButtonLabel = profile?.followedByMe ? 'Following' : 'Follow';
95
+ const activityIndicatorColor = profile?.followedByMe ? '#0f172a' : '#fff';
96
+
97
+ const unauthenticatedError = viewingOwnProfile
98
+ ? Boolean(
99
+ extractGraphQLErrors(meError)?.some((graphError) =>
100
+ graphError.message?.toUpperCase().includes('UNAUTHENTICATED')
101
+ )
102
+ )
103
+ : false;
104
+
105
+ const shouldPromptSignIn = viewingOwnProfile && (!token || unauthenticatedError);
106
+
107
+ const [updateProfileMutation, {loading: saving}] =
108
+ useUpdateProfileMutation();
109
+ const [followUserMutation, {loading: followLoading}] =
110
+ useFollowUserMutation();
111
+ const [unfollowUserMutation, {loading: unfollowLoading}] =
112
+ useUnfollowUserMutation();
113
+
114
+ const [displayName, setDisplayName] = useState('');
115
+ const [bio, setBio] = useState('');
116
+ const [formError, setFormError] = useState<string>();
117
+ const [successMessage, setSuccessMessage] = useState<string>();
118
+ const [followError, setFollowError] = useState<string>();
119
+ const followMutationInFlight = followLoading || unfollowLoading;
120
+ const isAuthenticated = Boolean(token);
121
+
122
+ useEffect(() => {
123
+ if (viewingOwnProfile && profile) {
124
+ setDisplayName(profile.displayName);
125
+ setBio(profile.bio ?? '');
126
+ }
127
+ }, [viewingOwnProfile, profile?.id, profile?.displayName, profile?.bio]);
128
+
129
+ useEffect(() => {
130
+ if (!successMessage) {
131
+ return;
132
+ }
133
+ const timeout = setTimeout(() => setSuccessMessage(undefined), 2500);
134
+ return () => clearTimeout(timeout);
135
+ }, [successMessage]);
136
+
137
+ useEffect(() => {
138
+ setFollowError(undefined);
139
+ }, [profile?.id]);
140
+
141
+ const posts = useMemo(() => selectProfilePosts(profile), [profile]);
142
+
143
+ const handleRefresh = useCallback(() => {
144
+ if (shouldPromptSignIn) return;
145
+ activeRefetch?.();
146
+ }, [shouldPromptSignIn, activeRefetch]);
147
+
148
+ const handleSave = useCallback(async () => {
149
+ if (!viewingOwnProfile) return;
150
+
151
+ const trimmedName = displayName.trim();
152
+ const trimmedBio = bio.trim();
153
+
154
+ if (!trimmedName) {
155
+ setFormError('Display name is required.');
156
+ return;
157
+ }
158
+
159
+ setFormError(undefined);
160
+ setSuccessMessage(undefined);
161
+
162
+ try {
163
+ const result = await updateProfileMutation({
164
+ variables: {
165
+ displayName: trimmedName,
166
+ bio: trimmedBio.length ? trimmedBio : null
167
+ }
168
+ });
169
+
170
+ if (result.data?.updateProfile) {
171
+ setSuccessMessage('Profile updated');
172
+ await refetchMe();
173
+ }
174
+ } catch (mutationError) {
175
+ console.warn('Failed to update profile', mutationError);
176
+ setFormError('We could not update your profile. Please try again.');
177
+ }
178
+ }, [viewingOwnProfile, displayName, bio, updateProfileMutation, refetchMe]);
179
+
180
+ const handleLogin = useCallback(() => {
181
+ auth?.login?.();
182
+ }, [auth]);
183
+
184
+ const handleLogout = useCallback(() => {
185
+ auth?.logout?.();
186
+ }, [auth]);
187
+
188
+ const handleToggleFollow = useCallback(async () => {
189
+ if (viewingOwnProfile || !profile?.id || followMutationInFlight) return;
190
+
191
+ if (!isAuthenticated) {
192
+ auth?.login?.();
193
+ return;
194
+ }
195
+
196
+ setFollowError(undefined);
197
+
198
+ try {
199
+ if (profile.followedByMe) {
200
+ await unfollowUserMutation({ variables: { userId: profile.id } });
201
+ } else {
202
+ await followUserMutation({ variables: { userId: profile.id } });
203
+ }
204
+ await activeRefetch?.();
205
+ } catch (mutationError) {
206
+ console.warn('Failed to update follow status', mutationError);
207
+ setFollowError('We could not update the follow status. Please try again.');
208
+ }
209
+ }, [
210
+ viewingOwnProfile,
211
+ profile?.id,
212
+ profile?.followedByMe,
213
+ followMutationInFlight,
214
+ isAuthenticated,
215
+ auth,
216
+ unfollowUserMutation,
217
+ followUserMutation,
218
+ activeRefetch
219
+ ]);
220
+
221
+ // --- small renderer helpers to lower complexity ---
222
+ const renderNoHandle = () => (
223
+ <View style={styles.centeredContainer}>
224
+ <View style={styles.placeholderCard}>
225
+ <Text style={styles.placeholderTitle}>No handle provided</Text>
226
+ <Text style={styles.placeholderBody}>
227
+ We could not determine which profile to show. Return to the feed and try again.
228
+ </Text>
229
+ </View>
230
+ </View>
231
+ );
232
+
233
+ const renderSignInPrompt = () => (
234
+ <View style={styles.centeredContainer}>
235
+ <View style={styles.placeholderCard}>
236
+ <Text style={styles.placeholderTitle}>Sign in to continue</Text>
237
+ <Text style={styles.placeholderBody}>
238
+ Log in to view and edit your profile, see your recent activity, and
239
+ connect with teammates.
240
+ </Text>
241
+ <TouchableOpacity
242
+ style={styles.primaryButton}
243
+ onPress={handleLogin}
244
+ accessibilityRole="button"
245
+ >
246
+ <Text style={styles.primaryButtonText}>Sign in</Text>
247
+ </TouchableOpacity>
248
+ {token ? (
249
+ <TouchableOpacity
250
+ style={styles.secondaryButton}
251
+ onPress={handleLogout}
252
+ accessibilityRole="button"
253
+ >
254
+ <Text style={styles.secondaryButtonText}>Log out</Text>
255
+ </TouchableOpacity>
256
+ ) : null}
257
+ </View>
258
+ </View>
259
+ );
260
+
261
+ const renderFollowAction = () => (
262
+ <TouchableOpacity
263
+ style={[
264
+ styles.followActionButton,
265
+ profile?.followedByMe ? styles.followingButton : styles.followButton,
266
+ followMutationInFlight ? styles.buttonDisabled : null
267
+ ]}
268
+ onPress={handleToggleFollow}
269
+ disabled={followMutationInFlight}
270
+ accessibilityRole="button"
271
+ >
272
+ {followMutationInFlight ? (
273
+ <ActivityIndicator color={activityIndicatorColor} />
274
+ ) : (
275
+ <Text
276
+ style={
277
+ profile?.followedByMe
278
+ ? styles.followingButtonText
279
+ : styles.followButtonText
280
+ }
281
+ >
282
+ {followButtonLabel}
283
+ </Text>
284
+ )}
285
+ </TouchableOpacity>
286
+ );
287
+
288
+ const renderProfileHeader = () => (
289
+ <View style={styles.card}>
290
+ <View style={styles.avatarCircle}>
291
+ {profile?.avatarUrl ? (
292
+ <Image
293
+ source={{ uri: profile.avatarUrl }}
294
+ style={styles.avatarImage}
295
+ resizeMode="cover"
296
+ />
297
+ ) : (
298
+ <PlaceholderAvatar />
299
+ )}
300
+ </View>
301
+
302
+ <View style={styles.headerText}>
303
+ <Text style={styles.displayName}>
304
+ {profile?.displayName ?? 'Set your display name'}
305
+ </Text>
306
+ <Text style={styles.handleText}>
307
+ {profile ? `@${profile.handle}` : 'No handle yet'}
308
+ </Text>
309
+
310
+ <View style={styles.metricsRow}>
311
+ <View style={styles.metricItem}>
312
+ <Text style={styles.metricValue}>
313
+ {profile?.followersCount ?? 0}
314
+ </Text>
315
+ <Text style={styles.metricLabel}>Followers</Text>
316
+ </View>
317
+ <View style={styles.metricItem}>
318
+ <Text style={styles.metricValue}>
319
+ {profile?.followingCount ?? 0}
320
+ </Text>
321
+ <Text style={styles.metricLabel}>Following</Text>
322
+ </View>
323
+ </View>
324
+ </View>
325
+
326
+ {viewingOwnProfile ? (
327
+ <TouchableOpacity
328
+ style={styles.logoutChip}
329
+ onPress={handleLogout}
330
+ accessibilityRole="button"
331
+ >
332
+ <Text style={styles.logoutChipText}>Log out</Text>
333
+ </TouchableOpacity>
334
+ ) : null}
335
+
336
+ {viewingOwnProfile ? null : renderFollowAction()}
337
+ </View>
338
+ );
339
+
340
+
341
+ const renderEditableProfile = () => (
342
+ <View style={styles.card}>
343
+ <Text style={styles.sectionTitle}>Profile details</Text>
344
+
345
+ <View style={styles.formGroup}>
346
+ <Text style={styles.label}>Display name</Text>
347
+ <TextInput
348
+ value={displayName}
349
+ onChangeText={setDisplayName}
350
+ placeholder="How should teammates see you?"
351
+ style={styles.textInput}
352
+ editable={!saving}
353
+ autoCapitalize="words"
354
+ />
355
+ </View>
356
+
357
+ <View style={styles.formGroup}>
358
+ <Text style={styles.label}>Bio</Text>
359
+ <TextInput
360
+ value={bio}
361
+ onChangeText={setBio}
362
+ placeholder="Share a short introduction"
363
+ style={[styles.textInput, styles.bioInput]}
364
+ multiline
365
+ numberOfLines={4}
366
+ textAlignVertical="top"
367
+ editable={!saving}
368
+ />
369
+ </View>
370
+
371
+ {formError ? <Text style={styles.errorText}>{formError}</Text> : null}
372
+ {successMessage ? <Text style={styles.successText}>{successMessage}</Text> : null}
373
+
374
+ <TouchableOpacity
375
+ style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
376
+ onPress={handleSave}
377
+ disabled={saving}
378
+ accessibilityRole="button"
379
+ >
380
+ {saving ? <ActivityIndicator color="#fff" /> : <Text style={styles.primaryButtonText}>Save changes</Text>}
381
+ </TouchableOpacity>
382
+ </View>
383
+ );
384
+
385
+ const aboutSection = profile?.bio ? (
386
+ <View style={styles.card}>
387
+ <Text style={styles.sectionTitle}>About</Text>
388
+ <Text style={styles.readOnlyBio}>{profile.bio}</Text>
389
+ </View>
390
+ ) : null;
391
+
392
+ const renderRecentPosts = () => (
393
+ <View style={styles.card}>
394
+ <Text style={styles.sectionTitle}>Recent posts</Text>
395
+
396
+ {activeLoading && !profile ? <ActivityIndicator style={styles.postsLoading} /> : null}
397
+
398
+ {!activeLoading && posts.length === 0 ? (
399
+ <Text style={styles.placeholderBody}>
400
+ {viewingOwnProfile
401
+ ? 'You have not shared anything yet. Post from the feed to see it listed here.'
402
+ : 'No recent posts to show yet. Check back later!'}
403
+ </Text>
404
+ ) : null}
405
+
406
+ {posts.map((post) => (
407
+ <View key={post.id} style={styles.postCard}>
408
+ <Text style={styles.postTimestamp}>
409
+ {formatRelativeProfileTimestamp(post.createdAt)}
410
+ </Text>
411
+ <Text style={styles.postBody}>{post.body}</Text>
412
+ </View>
413
+ ))}
414
+ </View>
415
+ );
416
+
417
+ const renderActiveError = () =>
418
+ activeError && !unauthenticatedError ? (
419
+ <View style={styles.errorCard}>
420
+ <Text style={styles.errorTitle}>We hit a snag</Text>
421
+ <Text style={styles.errorBody}>
422
+ Something went wrong while loading your profile. Pull to refresh or
423
+ try again.
424
+ </Text>
425
+ </View>
426
+ ) : null;
427
+
428
+ const renderProfileNotFound = () =>
429
+ !activeLoading && !profile && !activeError ? (
430
+ <View style={styles.errorCard}>
431
+ <Text style={styles.errorTitle}>Profile not found</Text>
432
+ <Text style={styles.errorBody}>
433
+ This handle does not exist or is no longer available.
434
+ </Text>
435
+ </View>
436
+ ) : null;
437
+
438
+ // --- top-level early returns to simplify main render ---
439
+ if (!viewingOwnProfile && trimmedHandle.length === 0) return renderNoHandle();
440
+ if (shouldPromptSignIn) return renderSignInPrompt();
441
+
442
+ return (
443
+ <ScrollView
444
+ style={styles.scroll}
445
+ contentContainerStyle={styles.scrollContent}
446
+ keyboardShouldPersistTaps="handled"
447
+ refreshControl={
448
+ <RefreshControl refreshing={activeLoading} onRefresh={handleRefresh} />
449
+ }
450
+ >
451
+ {renderProfileHeader()}
452
+
453
+ {!viewingOwnProfile && followError ? (
454
+ <Text style={styles.errorText}>{followError}</Text>
455
+ ) : null}
456
+
457
+ {viewingOwnProfile ? renderEditableProfile() : aboutSection}
458
+
459
+ {renderRecentPosts()}
460
+
461
+ {renderActiveError()}
462
+
463
+ {renderProfileNotFound()}
464
+ </ScrollView>
465
+ );
466
+ }
467
+
468
+ const {color, spacing, radius, shadow} = tokens;
469
+ const profileCardRecipe = cardRecipes.surface;
470
+ const postCardRecipe = cardRecipes.subdued;
471
+ const placeholderPalette = placeholderAvatarRecipe;
472
+
473
+ const styles = StyleSheet.create({
474
+ scroll: {
475
+ flex: 1,
476
+ backgroundColor: color.surfaceMuted
477
+ },
478
+ scrollContent: {
479
+ padding: spacing.lg,
480
+ gap: spacing.lg
481
+ },
482
+ centeredContainer: {
483
+ flex: 1,
484
+ alignItems: 'center',
485
+ justifyContent: 'center',
486
+ padding: spacing.xxl,
487
+ backgroundColor: color.surfaceMuted
488
+ },
489
+ placeholderCard: {
490
+ width: '100%',
491
+ maxWidth: 360,
492
+ backgroundColor: color[profileCardRecipe.backgroundColor],
493
+ borderRadius: radius[profileCardRecipe.radius],
494
+ padding: spacing.xxl,
495
+ alignItems: 'center',
496
+ gap: spacing.lg,
497
+ ...shadow[profileCardRecipe.elevation]
498
+ },
499
+ placeholderTitle: {
500
+ fontSize: 20,
501
+ fontWeight: '700',
502
+ color: color.text,
503
+ textAlign: 'center'
504
+ },
505
+ placeholderBody: {
506
+ fontSize: 14,
507
+ color: color.textMuted,
508
+ textAlign: 'center',
509
+ lineHeight: 20
510
+ },
511
+ primaryButton: {
512
+ width: '100%',
513
+ backgroundColor: color.primary,
514
+ borderRadius: radius.pill,
515
+ paddingVertical: spacing.mdPlus,
516
+ paddingHorizontal: spacing.xxl,
517
+ alignItems: 'center',
518
+ justifyContent: 'center',
519
+ ...shadow.md,
520
+ shadowColor: color.primary
521
+ },
522
+ primaryButtonText: {
523
+ fontSize: 16,
524
+ fontWeight: '600',
525
+ color: color.surface
526
+ },
527
+ secondaryButton: {
528
+ width: '100%',
529
+ borderRadius: radius.pill,
530
+ paddingVertical: spacing.md,
531
+ alignItems: 'center',
532
+ justifyContent: 'center',
533
+ borderWidth: 1,
534
+ borderColor: color.borderStrong
535
+ },
536
+ secondaryButtonText: {
537
+ fontSize: 15,
538
+ fontWeight: '600',
539
+ color: color.text
540
+ },
541
+ buttonDisabled: {
542
+ opacity: 0.65
543
+ },
544
+ card: {
545
+ backgroundColor: color[profileCardRecipe.backgroundColor],
546
+ borderRadius: radius[profileCardRecipe.radius],
547
+ padding: spacing[profileCardRecipe.padding],
548
+ gap: spacing.lg,
549
+ ...shadow[profileCardRecipe.elevation]
550
+ },
551
+ avatarCircle: {
552
+ width: 96,
553
+ height: 96,
554
+ borderRadius: 48,
555
+ overflow: 'hidden',
556
+ backgroundColor: color[placeholderPalette.container],
557
+ alignSelf: 'center',
558
+ alignItems: 'center',
559
+ justifyContent: 'center'
560
+ },
561
+ avatarImage: {
562
+ width: '100%',
563
+ height: '100%'
564
+ },
565
+ placeholderAvatar: {
566
+ width: '100%',
567
+ height: '100%',
568
+ alignItems: 'center',
569
+ justifyContent: 'center'
570
+ },
571
+ placeholderHead: {
572
+ width: 44,
573
+ height: 44,
574
+ borderRadius: 22,
575
+ backgroundColor: color[placeholderPalette.head]
576
+ },
577
+ placeholderShoulders: {
578
+ width: 68,
579
+ height: 36,
580
+ borderRadius: 18,
581
+ backgroundColor: color[placeholderPalette.shoulders],
582
+ marginTop: spacing.sm
583
+ },
584
+ headerText: {
585
+ alignItems: 'center',
586
+ gap: spacing.xxs
587
+ },
588
+ displayName: {
589
+ fontSize: 22,
590
+ fontWeight: '700',
591
+ color: color.text
592
+ },
593
+ handleText: {
594
+ fontSize: 14,
595
+ color: color.textSubdued
596
+ },
597
+ metricsRow: {
598
+ flexDirection: 'row',
599
+ gap: spacing.xxl,
600
+ marginTop: spacing.sm
601
+ },
602
+ metricItem: {
603
+ alignItems: 'center'
604
+ },
605
+ metricValue: {
606
+ fontSize: 18,
607
+ fontWeight: '700',
608
+ color: color.text
609
+ },
610
+ metricLabel: {
611
+ fontSize: 12,
612
+ color: color.textMuted,
613
+ textTransform: 'uppercase',
614
+ letterSpacing: 0.6
615
+ },
616
+ logoutChip: {
617
+ alignSelf: 'center',
618
+ borderRadius: radius.pill,
619
+ borderWidth: 1,
620
+ borderColor: color.borderStrong,
621
+ paddingHorizontal: spacing.lg,
622
+ paddingVertical: spacing.sm
623
+ },
624
+ logoutChipText: {
625
+ fontSize: 12,
626
+ fontWeight: '600',
627
+ color: color.text
628
+ },
629
+ followActionButton: {
630
+ marginTop: spacing.md,
631
+ alignSelf: 'center',
632
+ borderRadius: radius.pill,
633
+ paddingHorizontal: spacing.xxl,
634
+ paddingVertical: spacing.smPlus,
635
+ minWidth: 120,
636
+ alignItems: 'center'
637
+ },
638
+ followButton: {
639
+ backgroundColor: color.primary
640
+ },
641
+ followingButton: {
642
+ backgroundColor: color.surfaceSubdued
643
+ },
644
+ followButtonText: {
645
+ fontSize: 14,
646
+ fontWeight: '600',
647
+ color: color.surface
648
+ },
649
+ followingButtonText: {
650
+ fontSize: 14,
651
+ fontWeight: '600',
652
+ color: color.text
653
+ },
654
+ sectionTitle: {
655
+ fontSize: 16,
656
+ fontWeight: '700',
657
+ color: color.text
658
+ },
659
+ readOnlyBio: {
660
+ fontSize: 15,
661
+ lineHeight: 20,
662
+ color: color.text
663
+ },
664
+ formGroup: {
665
+ gap: spacing.sm
666
+ },
667
+ label: {
668
+ fontSize: 13,
669
+ fontWeight: '600',
670
+ color: color.text
671
+ },
672
+ textInput: {
673
+ borderRadius: radius.md,
674
+ borderWidth: 1,
675
+ borderColor: color.borderStrong,
676
+ paddingHorizontal: spacing.mdPlus,
677
+ paddingVertical: spacing.md,
678
+ fontSize: 15,
679
+ color: color.text,
680
+ backgroundColor: color.surfaceAlt
681
+ },
682
+ bioInput: {
683
+ minHeight: 120
684
+ },
685
+ successText: {
686
+ fontSize: 13,
687
+ color: '#1d7e1f',
688
+ fontWeight: '600'
689
+ },
690
+ errorText: {
691
+ fontSize: 13,
692
+ color: '#d22c2c',
693
+ fontWeight: '600'
694
+ },
695
+ postsLoading: {
696
+ marginTop: spacing.md
697
+ },
698
+ postCard: {
699
+ borderRadius: radius[postCardRecipe.radius],
700
+ borderWidth: postCardRecipe.borderColor ? 1 : 0,
701
+ borderColor: postCardRecipe.borderColor ? color[postCardRecipe.borderColor] : undefined,
702
+ padding: spacing[postCardRecipe.padding],
703
+ gap: spacing.sm,
704
+ backgroundColor: color[postCardRecipe.backgroundColor],
705
+ marginTop: spacing.md
706
+ },
707
+ postTimestamp: {
708
+ fontSize: 12,
709
+ color: color.textMuted
710
+ },
711
+ postBody: {
712
+ fontSize: 15,
713
+ color: color.text,
714
+ lineHeight: 20
715
+ },
716
+ errorCard: {
717
+ backgroundColor: '#fff4f4',
718
+ borderRadius: radius.lg,
719
+ padding: spacing.lg,
720
+ borderWidth: 1,
721
+ borderColor: '#f6d5d5',
722
+ gap: spacing.xs
723
+ },
724
+ errorTitle: {
725
+ fontSize: 15,
726
+ fontWeight: '700',
727
+ color: '#b11f1f'
728
+ },
729
+ errorBody: {
730
+ fontSize: 13,
731
+ color: '#7a2f2f',
732
+ lineHeight: 18
733
+ }
734
+ });