Skip to content

Commit 9e1ad7a

Browse files
committed
feat: Add public profile
1 parent de81416 commit 9e1ad7a

File tree

13 files changed

+576
-12
lines changed

13 files changed

+576
-12
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Button } from '@heroui/button';
2+
import { zodResolver } from '@hookform/resolvers/zod';
3+
import { updatePublicProfileInfo } from '@lib/api/backend';
4+
import { buildPublicProfileSchema } from '@schemas/profile';
5+
import InputWithLabel from '@shared/InputWithLabel';
6+
import Label from '@shared/Label';
7+
import StyledInput from '@shared/StyledInput';
8+
import { PublicProfileInfo } from '@typedefs/general';
9+
import { useMemo, useState } from 'react';
10+
import { Controller, FormProvider, useForm } from 'react-hook-form';
11+
import toast from 'react-hot-toast';
12+
import z from 'zod';
13+
14+
const PLATFORM_NAMES = {
15+
Linkedin: 'LinkedIn',
16+
};
17+
18+
export default function EditPublicProfile({
19+
profileInfo,
20+
brandingPlatforms,
21+
setEditing,
22+
onEdit,
23+
}: {
24+
profileInfo: PublicProfileInfo;
25+
brandingPlatforms: string[];
26+
setEditing: (editing: boolean) => void;
27+
onEdit: () => void;
28+
}) {
29+
const schema = useMemo(() => buildPublicProfileSchema(brandingPlatforms), [brandingPlatforms]);
30+
type FormValues = z.infer<typeof schema>;
31+
32+
const [isLoading, setLoading] = useState<boolean>(false);
33+
34+
const defaultValues: FormValues = useMemo(() => {
35+
const emptyLinks = brandingPlatforms.reduce(
36+
(acc, platform) => {
37+
acc[platform] = '';
38+
return acc;
39+
},
40+
{} as Record<string, string>,
41+
);
42+
43+
const values = {
44+
name: profileInfo.name ?? '',
45+
description: profileInfo.description ?? '',
46+
links: {
47+
...emptyLinks,
48+
...profileInfo.links,
49+
},
50+
};
51+
52+
return values;
53+
}, [profileInfo, brandingPlatforms]);
54+
55+
const form = useForm<FormValues>({
56+
resolver: zodResolver(schema),
57+
mode: 'onTouched',
58+
defaultValues,
59+
shouldUnregister: true,
60+
});
61+
62+
const { control } = form;
63+
64+
const onSubmit = async (data: FormValues) => {
65+
console.log(data);
66+
setLoading(true);
67+
68+
try {
69+
await updatePublicProfileInfo(data);
70+
toast.success('Public profile updated successfully.');
71+
onEdit();
72+
} catch (error) {
73+
console.error('Error updating public profile info.');
74+
} finally {
75+
setLoading(false);
76+
}
77+
};
78+
79+
const onError = (errors: any) => {
80+
console.log('Validation errors:', errors);
81+
};
82+
83+
return (
84+
<FormProvider {...form}>
85+
<form onSubmit={form.handleSubmit(onSubmit, onError)}>
86+
<div className="col gap-2">
87+
<InputWithLabel name="name" label="Name" placeholder="" />
88+
<InputWithLabel name="description" label="Description" placeholder="None" />
89+
90+
<Label value="Links" />
91+
{brandingPlatforms
92+
.sort((a, b) => a.localeCompare(b))
93+
.map((platform, index) => {
94+
return (
95+
<Controller
96+
key={index}
97+
name={`links.${platform}`}
98+
control={control}
99+
render={({ field, fieldState }) => {
100+
return (
101+
<StyledInput
102+
placeholder={PLATFORM_NAMES[platform] ?? platform}
103+
value={field.value ?? ''}
104+
onChange={(e) => {
105+
const value = e.target.value;
106+
field.onChange(value);
107+
}}
108+
onBlur={field.onBlur}
109+
isInvalid={!!fieldState.error}
110+
errorMessage={fieldState.error?.message}
111+
/>
112+
);
113+
}}
114+
/>
115+
);
116+
})}
117+
118+
<div className="row mt-2 justify-between">
119+
<Button
120+
className="h-9 border-2 border-slate-200 bg-white data-[hover=true]:opacity-65!"
121+
color="default"
122+
size="sm"
123+
variant="solid"
124+
onPress={() => setEditing(false)}
125+
>
126+
<div className="text-sm">Cancel</div>
127+
</Button>
128+
129+
<Button className="h-9" type="submit" color="primary" size="sm" variant="solid" isLoading={isLoading}>
130+
<div className="text-sm">Update profile</div>
131+
</Button>
132+
</div>
133+
</div>
134+
</form>
135+
</FormProvider>
136+
);
137+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Button } from '@heroui/button';
2+
import { uploadProfileImage } from '@lib/api/backend';
3+
import { useCallback, useRef } from 'react';
4+
import toast from 'react-hot-toast';
5+
6+
export default function ImageUpload({
7+
onUpload,
8+
setImageLoading,
9+
}: {
10+
onUpload: () => void;
11+
setImageLoading: (loading: boolean) => void;
12+
}) {
13+
const inputRef = useRef<HTMLInputElement | null>(null);
14+
15+
const handleFileChange = useCallback(
16+
async (event: React.ChangeEvent<HTMLInputElement>) => {
17+
setImageLoading(true);
18+
19+
const file = event.target.files?.[0];
20+
21+
if (!file) {
22+
return;
23+
}
24+
25+
if (file.size > 500_000) {
26+
const message = 'Image size must not exceed 500 KB.';
27+
toast.error(message);
28+
event.target.value = '';
29+
return;
30+
}
31+
32+
try {
33+
await uploadProfileImage(file);
34+
35+
setTimeout(() => {
36+
// Takes into account the response time of the image request
37+
toast.success('Profile image updated successfully.');
38+
}, 500);
39+
onUpload();
40+
} catch (err) {
41+
console.error('Profile image upload failed:', err);
42+
toast.error('Failed to upload profile image.');
43+
setImageLoading(false);
44+
} finally {
45+
// Reset the input so the same file can be uploaded twice in a row if needed
46+
event.target.value = '';
47+
}
48+
},
49+
[onUpload],
50+
);
51+
52+
const handleButtonClick = useCallback(() => {
53+
inputRef.current?.click();
54+
}, []);
55+
56+
return (
57+
<div className="row gap-2.5">
58+
<input
59+
ref={inputRef}
60+
id="image-input"
61+
type="file"
62+
accept="image/*"
63+
onChange={handleFileChange}
64+
className="hidden"
65+
/>
66+
67+
<Button
68+
className="h-9 border-2 border-slate-200 bg-white data-[hover=true]:opacity-65!"
69+
color="default"
70+
size="sm"
71+
variant="solid"
72+
onPress={handleButtonClick}
73+
>
74+
<div className="text-sm">Upload image...</div>
75+
</Button>
76+
</div>
77+
);
78+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import ProfileSection from './ProfileSection';
2+
import PublicProfile from './PublicProfile';
3+
4+
export default function Profile() {
5+
return (
6+
<div className="col items-center gap-6">
7+
<ProfileSection title="Public Profile">
8+
<PublicProfile />
9+
</ProfileSection>
10+
11+
<ProfileSection title="Account">
12+
<div>PersonalInformation</div>
13+
{/* TODO: USDC Balance */}
14+
{/* <PersonalInformation /> */}
15+
</ProfileSection>
16+
</div>
17+
);
18+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function ProfileSection({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
2+
return (
3+
<div className="col w-full max-w-lg items-center gap-3.5">
4+
<div className="big-title">{title}</div>
5+
{children}
6+
</div>
7+
);
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { BorderedCard } from '@shared/cards/BorderedCard';
2+
3+
export default function ProfileSectionWrapper({ children }: { children: React.ReactNode }) {
4+
return (
5+
// TODO: Check rounding
6+
<BorderedCard disableWrapper>
7+
<div className="col gap-4 px-4 py-3 sm:px-5 sm:py-4">{children}</div>
8+
</BorderedCard>
9+
);
10+
}

0 commit comments

Comments
 (0)