Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

KB-187 implement profile page #340

Merged
merged 14 commits into from
Jan 14, 2025
7,309 changes: 2,678 additions & 4,631 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
Expand Down
6 changes: 3 additions & 3 deletions src/actions/auth.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function loginWithCredentials(username: string, password: string, r
return null;
}

const userResponse = await campusFetch('account/info', {
const userResponse = await campusFetch('profile', {
method: 'GET',
headers: {
Authorization: `Bearer ${jsonResponse.access_token}`,
Expand All @@ -47,7 +47,7 @@ export async function loginWithCredentials(username: string, password: string, r
return null;
}

const user = await userResponse.json();
const user: User = await userResponse.json();

const maxAge = rememberMe ? COOKIE_MAX_AGE : undefined;

Expand Down Expand Up @@ -90,7 +90,7 @@ export async function resetPassword(username: string, recaptchaToken: string) {
}

export async function getUserDetails(): Promise<User | null> {
const userResponse = await campusFetch('account/info', {
const userResponse = await campusFetch('profile', {
method: 'GET',
});

Expand Down
108 changes: 108 additions & 0 deletions src/actions/profile.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use server';

import { Contact, ContactType } from '@/types/contact';
import { campusFetch } from '@/lib/client';
import { revalidatePath } from 'next/cache';
import { getUserDetails } from '@/actions/auth.actions';

export async function getContacts(): Promise<Contact[]> {
try {
const response = await campusFetch('profile/contacts');

return response.json();
} catch (error) {
return [];
}
}

export async function getContactTypes(): Promise<ContactType[]> {
try {
const response = await campusFetch('profile/contacts/types');

return response.json();
} catch (error) {
return [];
}
}

export async function createContact(typeId: number, value: string) {
try {
await campusFetch('profile/contacts', {
method: 'POST',
body: JSON.stringify({ typeId, value }),
});
revalidatePath('/profile');
} catch (error) {
throw new Error('Error while creating contact');
}
}

export async function updateContact(id: number, typeId: number, value: string) {
try {
await campusFetch(`profile/contacts/${id}`, {
method: 'PUT',
body: JSON.stringify({ typeId, value }),
});
revalidatePath('/profile');
} catch (error) {
throw new Error('Error while updating contact');
}
}

export async function deleteContact(id: number) {
try {
await campusFetch(`profile/contacts/${id}`, {
method: 'DELETE',
});
revalidatePath('/profile');
} catch (error) {
throw new Error('Error while deleting contact');
}
}

export async function setIntellectAgreement(agree: boolean) {
try {
await campusFetch('profile/intellect/agreement', {
method: 'POST',
body: JSON.stringify({ agree }),
});
return getUserDetails();
} catch (error) {
throw new Error('Error while setting intellect agreement');
}
}

export async function updateEnglishFullName(fullNameEnglish: string) {
try {
await campusFetch('profile', {
method: 'PUT',
body: JSON.stringify({ fullNameEnglish }),
});
return getUserDetails();
} catch (error) {
throw new Error('Error while updating English full name');
}
}

export async function updateIntellectInfo(credo: string, scientificInterests: string) {
try {
await campusFetch('profile/intellect', {
method: 'PUT',
body: JSON.stringify({ credo, scientificInterests }),
});
return getUserDetails();
} catch (error) {
throw new Error('Error while updating intellect info');
}
}

export async function acceptCodeOfHonor() {
try {
await campusFetch('profile/code-of-honor', {
method: 'PUT',
});
return getUserDetails();
} catch (error) {
throw new Error('Error while accepting code of honor');
}
}
67 changes: 67 additions & 0 deletions src/app/[locale]/(private)/profile/components/code-of-honor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client';

import { Heading6 } from '@/components/typography/headers';
import { Separator } from '@/components/ui/separator';
import { Paragraph } from '@/components/typography/paragraph';
import { Button } from '@/components/ui/button';
import { useIsMobile } from '@/hooks/use-mobile';
import { useLocalStorage } from '@/hooks/use-storage';
import { User } from '@/types/user';
import { acceptCodeOfHonor } from '@/actions/profile.actions';
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Check } from '@/app/images';
import { useServerErrorToast } from '@/hooks/use-server-error-toast';
import { Link } from '@/i18n/routing';

export function CodeOfHonor() {
const { errorToast } = useServerErrorToast();

const isMobile = useIsMobile();

const [user, setUser] = useLocalStorage<User>('user');

const [loading, setLoading] = useState(false);

const t = useTranslations('private.profile');

const handleAcceptCodeOfHonor = async () => {
setLoading(true);
const res = await acceptCodeOfHonor();
setLoading(false);

if (!res) {
errorToast();
return;
niravzi marked this conversation as resolved.
Show resolved Hide resolved
}
setUser(res);
};

return (
<div className="flex flex-col gap-3">
<Heading6>{t('codeOfHonor.title')}</Heading6>
<Separator />
{t.rich('codeOfHonor.content', {
documentsLink: (chunks) => <Link href="/kpi-documents">{chunks}</Link>,
paragraph: (chunks) => <Paragraph className="m-0 text-lg">{chunks}</Paragraph>,
})}
{user?.codeOfHonorSignDate ? (
<div className="flex flex-col gap-1">
<Paragraph>{t('codeOfHonor.agreement')}</Paragraph>
<Paragraph className="m-0">{user?.codeOfHonorSignDate}</Paragraph>
</div>
) : (
<Button
className="ml-auto w-fit"
loading={loading}
onClick={handleAcceptCodeOfHonor}
size={isMobile ? 'medium' : 'big'}
icon={<Check />}
iconPosition="end"
>
{t('button.agree')}
</Button>
)}
</div>
);
}
134 changes: 134 additions & 0 deletions src/app/[locale]/(private)/profile/components/contacts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
'use client';

import { Heading6 } from '@/components/typography/headers';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { Contact, ContactType } from '@/types/contact';
import { createContact, deleteContact, updateContact } from '@/actions/profile.actions';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslations } from 'next-intl';
import { EditableField } from '@/app/[locale]/(private)/profile/components/editable-field';
import { Fragment } from 'react';

interface Props {
contacts: Contact[];
contactTypes: ContactType[];
}

export function Contacts({ contacts, contactTypes }: Props) {
const t = useTranslations('private.profile');

const FormSchema = z.object({
contactValue: z.string().trim().min(1),
typeId: z.string().min(1),
});

type FormData = z.infer<typeof FormSchema>;

const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
contactValue: '',
typeId: '',
},
});

const handleFormSubmit = async (data: FormData) => {
await createContact(parseInt(data.typeId), data.contactValue);
form.reset();
};

const handleDeleteContact = async (id: number) => {
await deleteContact(id);
form.reset();
};

const handleUpdateContact = async (id: number, typeId: number, value: string) => {
await updateContact(id, typeId, value);
};

return (
<div className="flex flex-col">
<div className="flex w-full flex-col gap-3">
<Heading6>{t('contact.title')}</Heading6>
<Separator />
<div className="flex w-full flex-col gap-4">
{contacts.map((contact) => (
<Fragment key={contact.id}>
<EditableField
label={contact.type.name}
value={contact.value}
onSave={(newValue) => handleUpdateContact(contact.id, contact.type.id, newValue)}
onDelete={() => handleDeleteContact(contact.id)}
/>
</Fragment>
))}
</div>
</div>

<div className="mt-6 flex flex-col gap-3">
<Heading6>{t('contact.add-contact')}</Heading6>
<Separator />
<Form {...form}>
<form className="flex flex-col" onSubmit={form.handleSubmit(handleFormSubmit)}>
<div className="flex flex-col gap-5 md:flex-row">
<FormField
control={form.control}
name="typeId"
render={({ field }) => (
<FormItem className="w-full gap-2">
<FormLabel className="text-base" htmlFor="typeId">
{t('contact.contact-type')}
</FormLabel>
<Select onValueChange={field.onChange} {...field}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('contact.choose-contact-type')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
{contactTypes.map((type) => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactValue"
render={({ field }) => (
<FormItem className="w-full gap-2">
<FormLabel className="text-base" htmlFor="contactValue">
{t('contact.title')}
</FormLabel>
<Input {...field} value={field.value || ''} />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="ml-auto mt-3 w-fit"
variant="secondary"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
{t('button.add-new')}
</Button>
</form>
</Form>
</div>
</div>
);
}
Loading