Skip to content

Commit 428fe3e

Browse files
committed
feat: User profile card
1 parent 9484a5a commit 428fe3e

8 files changed

Lines changed: 243 additions & 11 deletions

File tree

src/admin/app.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import favicon from "./extensions/favicon.png";
33
import { ChartPie, User } from '@strapi/icons'
44
import ViewerWidget from './widgets/ViewerWidget';
55
import ApplicantManager from './pages/ApplicantManager';
6+
import ProfileWidget from './widgets/ProfileWidget';
67
// import ApplicantManager from './pages/ApplicantManager';
78

89
export default {
@@ -29,6 +30,18 @@ export default {
2930
},
3031
});
3132

33+
app.widgets.register({
34+
id: 'profile-info',
35+
icon: User,
36+
title: {
37+
id: 'profile.info.title',
38+
defaultMessage: 'My Profile',
39+
},
40+
component: async () => {
41+
return ProfileWidget;
42+
},
43+
});
44+
3245
app.addMenuLink({
3346
to: '/plugins/applicant-manager',
3447
icon: User,
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { useEffect, useState } from 'react';
2+
import {
3+
Box,
4+
Flex,
5+
Typography,
6+
Avatar,
7+
Loader,
8+
Badge,
9+
Grid,
10+
Divider,
11+
} from '@strapi/design-system';
12+
import { useAuth, useFetchClient } from '@strapi/admin/strapi-admin';
13+
import styled from 'styled-components';
14+
import { User, Book, Calendar, Phone, Information } from '@strapi/icons';
15+
16+
const StyledContainer = styled(Box)`
17+
background: ${({ theme }) => theme.colors.neutral0};
18+
border-radius: 16px;
19+
border: 1px solid ${({ theme }) => theme.colors.neutral150};
20+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
21+
overflow: hidden;
22+
height: 100%;
23+
transition: transform 0.3s ease, box-shadow 0.3s ease;
24+
25+
&:hover {
26+
transform: translateY(-4px);
27+
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
28+
}
29+
`;
30+
31+
const HeaderBox = styled(Box)`
32+
background: linear-gradient(135deg, #1e1e2f 0%, #4a4a7d 100%);
33+
padding: ${({ theme }) => theme.spaces[7]};
34+
color: white;
35+
position: relative;
36+
`;
37+
38+
const AvatarWrapper = styled(Flex)`
39+
position: absolute;
40+
bottom: -40px;
41+
left: 32px;
42+
border: 5px solid white;
43+
border-radius: 50%;
44+
background: white;
45+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
46+
z-index: 2;
47+
`;
48+
49+
const ContentBox = styled(Box)`
50+
padding: ${({ theme }) => theme.spaces[12]} ${({ theme }) => theme.spaces[8]} ${({ theme }) => theme.spaces[7]} ${({ theme }) => theme.spaces[8]};
51+
`;
52+
53+
const InfoItem = ({ icon: Icon, label, value }: { icon: any, label: string, value: string }) => (
54+
<Flex gap={3} alignItems="center" marginBottom={5}>
55+
<Box padding={3} background="primary100" borderRadius="12px">
56+
<Icon width="18px" height="18px" fill="#4945ff" />
57+
</Box>
58+
<Box>
59+
<Typography variant="sigma" textColor="neutral500" fontWeight="bold" display="block" style={{ letterSpacing: '0.05em' }}>
60+
{label}
61+
</Typography>
62+
<Typography variant="omega" fontWeight="bold" textColor="neutral800">
63+
{value || 'Not set'}
64+
</Typography>
65+
</Box>
66+
</Flex>
67+
);
68+
69+
interface ProfileData {
70+
id: number;
71+
university: string;
72+
birth: string;
73+
phone: string;
74+
identifier: string;
75+
avatar: any;
76+
}
77+
78+
export default function ProfileWidget() {
79+
const user = useAuth('ProfileWidget', (state: any) => state.user);
80+
const { get } = useFetchClient();
81+
const [profile, setProfile] = useState<ProfileData | null>(null);
82+
const [loading, setLoading] = useState(true);
83+
const [error, setError] = useState(false);
84+
85+
useEffect(() => {
86+
const fetchProfile = async () => {
87+
if (!user?.id) return;
88+
try {
89+
setLoading(true);
90+
// Fetch profiles populated with user and avatar, filtered by the authenticated user's ID
91+
const response = await get(`/api/profiles?filters[user][id][$eq]=${user.id}&populate=*`);
92+
93+
if (response.data && response.data.data && response.data.data.length > 0) {
94+
const item = response.data.data[0];
95+
setProfile({
96+
id: item.id,
97+
university: item.university,
98+
birth: item.birth,
99+
phone: item.phone,
100+
identifier: item.identifier,
101+
avatar: item.avatar,
102+
});
103+
}
104+
} catch (err) {
105+
console.error('Error fetching profile:', err);
106+
setError(true);
107+
} finally {
108+
setLoading(false);
109+
}
110+
};
111+
112+
fetchProfile();
113+
}, [user, get]);
114+
115+
if (loading) {
116+
return (
117+
<StyledContainer>
118+
<Flex justifyContent="center" alignItems="center" height="300px">
119+
<Loader>Syncing your profile...</Loader>
120+
</Flex>
121+
</StyledContainer>
122+
);
123+
}
124+
125+
if (error || !user) {
126+
return (
127+
<StyledContainer>
128+
<Flex justifyContent="center" alignItems="center" height="300px" padding={6} direction="column" gap={4}>
129+
<Typography variant="beta" textColor="danger600">Profile Not Found</Typography>
130+
<Typography textColor="neutral600" textAlign="center">
131+
We couldn't retrieve your additional profile details. Please contact the administrator.
132+
</Typography>
133+
</Flex>
134+
</StyledContainer>
135+
);
136+
}
137+
138+
const fullName = `${user.firstname || ''} ${user.lastname || ''}`.trim() || user.username || 'Nuclear Member';
139+
const avatarUrl = profile?.avatar?.url ? (profile.avatar.url.startsWith('http') ? profile.avatar.url : `${window.location.origin}${profile.avatar.url}`) : null;
140+
const initials = fullName.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2);
141+
142+
return (
143+
<StyledContainer>
144+
<HeaderBox>
145+
<Flex justifyContent="space-between" alignItems="flex-start">
146+
<Typography variant="beta" fontWeight="bold" textColor="neutral0">
147+
Account Overview
148+
</Typography>
149+
<Badge variant="success">Verified</Badge>
150+
</Flex>
151+
<AvatarWrapper>
152+
<Avatar
153+
src={avatarUrl}
154+
alt={fullName}
155+
fallback={initials}
156+
width="90px"
157+
height="90px"
158+
/>
159+
</AvatarWrapper>
160+
</HeaderBox>
161+
162+
<ContentBox>
163+
<Box marginBottom={8}>
164+
<Typography variant="alpha" fontWeight="bold" textColor="neutral800" display="block">
165+
{fullName}
166+
</Typography>
167+
<Typography variant="epsilon" textColor="neutral500">
168+
Member of Nuclear Community
169+
</Typography>
170+
</Box>
171+
172+
<Grid.Root gap={4}>
173+
<Grid.Item col={6}>
174+
<InfoItem
175+
icon={Book}
176+
label="INSTITUTION"
177+
value={profile?.university}
178+
/>
179+
</Grid.Item>
180+
<Grid.Item col={6}>
181+
<InfoItem
182+
icon={Information}
183+
label="MEMBER ID"
184+
value={profile?.identifier}
185+
/>
186+
</Grid.Item>
187+
<Grid.Item col={6}>
188+
<InfoItem
189+
icon={Calendar}
190+
label="DATE OF BIRTH"
191+
value={profile?.birth ? new Date(profile.birth).toLocaleDateString(undefined, { dateStyle: 'long' }) : 'Not specified'}
192+
/>
193+
</Grid.Item>
194+
<Grid.Item col={6}>
195+
<InfoItem
196+
icon={Phone}
197+
label="CONTACT"
198+
value={profile?.phone}
199+
/>
200+
</Grid.Item>
201+
</Grid.Root>
202+
</ContentBox>
203+
</StyledContainer>
204+
);
205+
}

src/api/applicant/content-types/applicant/schema.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@
4646
},
4747
"accepted": {
4848
"type": "boolean",
49-
"private": true,
5049
"default": false
5150
}
5251
}
53-
}
52+
}

src/api/applicant/controllers/applicant.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ export default factories.createCoreController('api::applicant.applicant', ({ str
1717

1818
// 2. Find the "Author" role (Admin Role)
1919
const authorRole = await strapi.query('admin::role').findOne({
20-
where: { name: { $eq: 'Author' } }
20+
where: { name: { $eq: 'Anggota' } }
2121
});
22-
console.log((await strapi.query('admin::role').findMany()));
2322

2423
if (!authorRole) {
2524
return ctx.badRequest('Author role not found in admin roles. Please ensure a role with "Author" exists.');
@@ -39,14 +38,15 @@ export default factories.createCoreController('api::applicant.applicant', ({ str
3938
roles: [authorRole.id],
4039
isActive: true,
4140
});
42-
41+
4342
// 4. Create Profile
4443
// The Profile schema defines a relation to "admin::user", so we link the new admin user.
4544
const profile = await strapi.entityService.create('api::profile.profile', {
4645
data: {
4746
university: applicant.university,
4847
birth: applicant.birth,
4948
user: newUser.id,
49+
identifier: 'INYS-NEED-VERIFICATION',
5050
},
5151
});
5252

src/api/profile/content-types/profile/schema.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131
"required": false,
3232
"unique": false,
3333
"regex": "^628[0-9]+$"
34+
},
35+
"identifier": {
36+
"type": "string",
37+
"default": "INYS-NEED-VERIFICATION",
38+
"required": true
39+
},
40+
"avatar": {
41+
"type": "media",
42+
"multiple": false,
43+
"allowedTypes": [
44+
"images",
45+
"files"
46+
]
3447
}
3548
}
36-
}
49+
}

src/api/profile/routes/profile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ import { factories } from '@strapi/strapi';
66

77
export default factories.createCoreRouter('api::profile.profile',
88
{
9-
only:[]
9+
only: ['find']
1010
}
1111
);

src/extensions/documentation/documentation/1.0.0/full_documentation.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"name": "Apache 2.0",
1515
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
1616
},
17-
"x-generation-date": "2026-01-14T10:32:22.979Z"
17+
"x-generation-date": "2026-01-15T07:14:46.474Z"
1818
},
1919
"servers": [
2020
{

types/generated/contentTypes.d.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,9 +441,7 @@ export interface ApiApplicantApplicant extends Struct.CollectionTypeSchema {
441441
draftAndPublish: false;
442442
};
443443
attributes: {
444-
accepted: Schema.Attribute.Boolean &
445-
Schema.Attribute.Private &
446-
Schema.Attribute.DefaultTo<false>;
444+
accepted: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
447445
birth: Schema.Attribute.Date & Schema.Attribute.Required;
448446
createdAt: Schema.Attribute.DateTime;
449447
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
@@ -567,10 +565,14 @@ export interface ApiProfileProfile extends Struct.CollectionTypeSchema {
567565
draftAndPublish: false;
568566
};
569567
attributes: {
568+
avatar: Schema.Attribute.Media<'images' | 'files'>;
570569
birth: Schema.Attribute.Date & Schema.Attribute.Required;
571570
createdAt: Schema.Attribute.DateTime;
572571
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
573572
Schema.Attribute.Private;
573+
identifier: Schema.Attribute.String &
574+
Schema.Attribute.Required &
575+
Schema.Attribute.DefaultTo<'INYS-NEED-VERIFICATION'>;
574576
locale: Schema.Attribute.String & Schema.Attribute.Private;
575577
localizations: Schema.Attribute.Relation<
576578
'oneToMany',

0 commit comments

Comments
 (0)