Skip to content

Commit

Permalink
add ui for settings page
Browse files Browse the repository at this point in the history
  • Loading branch information
Markusplay committed Dec 29, 2024
1 parent c3b656f commit b852bf7
Show file tree
Hide file tree
Showing 8 changed files with 342 additions and 7 deletions.
2 changes: 1 addition & 1 deletion src/actions/group.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export async function searchByGroupName(search: string): Promise<Group[]> {
} catch (error) {
throw new Error('Error loading groups');
}
}
}
41 changes: 41 additions & 0 deletions src/actions/settings.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use server';

import { campusFetch } from '@/lib/client';

export async function changeEmail(email: string) {
try {
await campusFetch('account/info', {
method: 'PUT',
body: JSON.stringify({ email }),
});
} catch (error) {
throw new Error('Error changing email.');
}
}

export async function changePhoto(file: File) {
try {
const formData = new FormData();
formData.append('file', file, file.name);

await campusFetch('profile/photo', {
method: 'POST',
body: formData,
});
} catch (error) {
throw new Error('Error changing photo.');
}
}

export async function changePassword(newPassword: string, currentPassword: string) {
try {
const response = await campusFetch('account/info', {
method: 'POST',
body: JSON.stringify({ newPassword, currentPassword }),
});

return response.json();
} catch (error) {
throw new Error('Error changing password.');
}
}
34 changes: 30 additions & 4 deletions src/app/[locale]/(private)/profile-picture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,40 @@ import { User } from '@/types/user';
import { AvatarFallback } from '@radix-ui/react-avatar';
import { CircleUserRound } from 'lucide-react';

export const ProfilePicture = () => {
type Variant = 'xs' | 'sm' | 'base' | 'lg' | 'xl';

const sizeMap = {
xs: 'h-[28px] w-[28px]',
sm: 'h-[36px] w-[36px]',
base: 'h-[48px] w-[48px]',
lg: 'h-[56px] w-[56px]',
xl: 'h-[120px] w-[120px]',
};

const iconSizeMap = {
xs: 28,
sm: 36,
base: 48,
lg: 56,
xl: 120,
};

interface ProfilePictureProps {
variant?: Variant;
src?: string;
}

export const ProfilePicture = ({ variant = 'base', src }: ProfilePictureProps) => {
const [user] = useLocalStorage<User>('user');

const sizeClass = sizeMap[variant];
const iconSize = iconSizeMap[variant];

return (
<Avatar className="h-[48px] w-[48px]">
<AvatarImage src={user?.photo} />
<Avatar className={sizeClass}>
<AvatarImage src={src || user?.photo} />
<AvatarFallback>
<CircleUserRound width={48} height={48} className="text-basic-blue" strokeWidth={1} />
<CircleUserRound width={iconSize} height={iconSize} className="text-basic-blue" strokeWidth={1} />
</AvatarFallback>
</Avatar>
);
Expand Down
30 changes: 30 additions & 0 deletions src/app/[locale]/(private)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Heading1 } from '@/components/typography/headers';
import { SubLayout } from '../sub-layout';
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import SettingsForm from '@/app/[locale]/(private)/settings/settings-form';
import { Paragraph } from '@/components/typography/paragraph';

const INTL_NAMESPACE = 'private.settings';

export async function generateMetadata({ params: { locale } }: any) {
const t = await getTranslations({ locale, namespace: INTL_NAMESPACE });

return {
title: t('title'),
};
}

export default function SettingsPage() {
const t = useTranslations(INTL_NAMESPACE);

return (
<SubLayout pageTitle={t('title')}>
<div className="col-span-6">
<Heading1>{t('title')}</Heading1>
<Paragraph className="text-neutral-700">{t('subtitle')}</Paragraph>
<SettingsForm />
</div>
</SubLayout>
);
}
184 changes: 184 additions & 0 deletions src/app/[locale]/(private)/settings/settings-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
'use client';

import { Heading5 } from '@/components/typography/headers';
import { useTranslations } from 'next-intl';
import { ProfilePicture } from '@/app/[locale]/(private)/profile-picture';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import PasswordInput from '@/components/ui/password-input';
import { Button } from '@/components/ui/button';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLocalStorage } from '@/hooks/use-storage';
import { useServerErrorToast } from '@/hooks/use-server-error-toast';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { User } from '@/types/user';
import { useRef, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { changeEmail, changePassword, changePhoto } from '@/actions/settings.actions';
import { useIsMobile } from '@/hooks/use-mobile';

const INTL_NAMESPACE = 'private.settings';

interface SettingsFormProps {
className?: string;
}

export default function SettingsForm({ className }: SettingsFormProps) {
const t = useTranslations(INTL_NAMESPACE);
const { toast } = useToast();
const { errorToast } = useServerErrorToast();

const isMobile = useIsMobile();

const inputRef = useRef<HTMLInputElement>(null);

const [user] = useLocalStorage<User>('user');
const [file, setFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState(user?.photo);

const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
const file = files?.[0];
if (!files || !file) {
return;
}
setFile(file);
setPreviewImage(URL.createObjectURL(file));
};

const handleButtonClick = () => {
if (!inputRef || !inputRef.current) return;
inputRef.current.click();
};

const FormSchema = z
.object({
email: z
.string()
.min(1)
.email({ message: t('error.invalid-email') }),
currentPassword: z.string().optional(),
newPassword: z.string().optional(),
confirmPassword: z.string().optional(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t('error.password-mismatch'),
path: ['confirmPassword'],
});

type FormData = z.infer<typeof FormSchema>;

const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
email: user?.email || '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});

const handleFormSubmit = async (data: FormData) => {
try {
form.clearErrors();

if (user?.email.trim() !== data.email.trim()) {
await changeEmail(data.email.trim());
}

if (file) {
await changePhoto(file);
}

if (data.newPassword?.trim() && data.currentPassword?.trim()) {
await changePassword(data.newPassword, data.currentPassword);
}
toast({
title: t('success.update'),
});
} catch (error) {
errorToast();
}
};

return (
<Card className={cn(className)}>
<CardContent className="flex flex-col gap-8 space-y-1.5 p-10">
<Heading5>{t('section.photo')}</Heading5>
<div className="flex items-center gap-4">
<ProfilePicture variant="xl" src={previewImage} />
<Button className="h-fit" variant="secondary" onClick={handleButtonClick}>
{t('button.edit')}
</Button>
<input ref={inputRef} type="file" hidden onChange={handleFileUpload} />
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="flex flex-col items-start space-y-4">
<Heading5>{t('section.contacts')}</Heading5>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="my-6 grid w-full items-center gap-2">
<FormLabel htmlFor="email">E-mail</FormLabel>
<FormControl>
<Input {...field} placeholder="[email protected]" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<Heading5>{t('section.password')}</Heading5>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem className="my-6 grid w-full items-center gap-2">
<FormLabel htmlFor="currentPassword">{t('field.current-password')}</FormLabel>
<PasswordInput {...field} value={field.value || ''} />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem className="my-6 grid w-full items-center gap-2">
<FormLabel htmlFor="newPassword">{t('field.new-password')}</FormLabel>
<PasswordInput {...field} value={field.value || ''} />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="my-6 grid w-full items-center gap-2">
<FormLabel htmlFor="confirmPassword">{t('field.confirm-password')}</FormLabel>
<PasswordInput {...field} value={field.value || ''} />
{form.formState.errors.confirmPassword && (
<span className="text-red-500">{form.formState.errors.confirmPassword?.message}</span>
)}
</FormItem>
)}
/>
<FormMessage className="text-red-500">{form.formState.errors.root?.message}</FormMessage>
<Button
size={isMobile ? 'medium' : 'big'}
className="my-4 ml-auto"
type="submit"
// disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
{t('button.save-changes')}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
2 changes: 1 addition & 1 deletion src/components/ui/locale-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const LocaleSwitch = ({ className }: Props) => {
const getTitle = () => {
switch (locale) {
case LOCALE.UK:
return 'Switch to english 🇬🇧';
return 'Switch to English 🇬🇧';
default:
return 'Перейти на українську 🇺🇦';
}
Expand Down
29 changes: 28 additions & 1 deletion src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
}
},
"documents": {
"title": "KPI Documens",
"title": "KPI Documents",
"header": "Igor Sikorsky Kyiv Polytechnic Institute Documents",
"code-of-honor": "Code of honor",
"internal-regulations": "Internal regulations",
Expand All @@ -152,6 +152,33 @@
"contacts": {
"title": "Contacts",
"content": "<h3>Contact details</h3><p>Bureau office address: <addresslink>Ukraine, Kyiv, 14-v Polytechnic St.</addresslink>, Building 13, 4th floor, room 25</p><h3>Social networks</h3><p><githublink>KPI on GitHub</githublink><br></br><facebooklink>Facebook</facebooklink><br></br><twitterlink>Twitter</twitterlink><br></br><instagramlink>Instagram</instagramlink><br></br></p><h3>Support service</h3><p>If you have any questions, you can contact the support service:</p><complaintslink>Complaints and suggestions form</complaintslink><br></br><emaillink>Email</emaillink>"
},

"settings": {
"title": "Settings",
"subtitle": "Here you can change your photo, e-mail and password for your profile.",
"section": {
"photo": "Photo",
"contacts": "Contacts",
"password": "Password"
},
"button": {
"edit": "Edit",
"save-changes": "Save changes"
},
"field": {
"current-password": "Current password",
"new-password": "New password",
"confirm-password": "Confirm password",
"error": "Error"
},
"error": {
"invalid-email": "Invalid email",
"password-mismatch": "Password mismatch"
},
"success": {
"update": "Updated successfully"
}
}
}
}
27 changes: 27 additions & 0 deletions src/messages/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@
"title": "Контакти",
"header": "Контакти",
"content": "<h3>Контактнi данi</h3><p>Адреса конструкторського бюро: <addresslink>Україна, м. Київ, вул. Політехнічна 14-в</addresslink>, корпус 13, 4 поверх, 25 кабінет</p><h3>Соцiальнi мережi</h3><p><githublink>КПI у GitHub</githublink><br></br><facebooklink>Facebook</facebooklink><br></br><twitterlink>Twitter</twitterlink><br></br><instagramlink>Instagram</instagramlink><br></br></p><h3>Служба підтримки</h3><p>Якщо у вас є питання, ви можете звернутися до служби підтримки:</p><complaintslink>Форма скарг i пропозицiй</complaintslink><br></br><emaillink>Email</emaillink>"
},

"settings": {
"title": "Налаштування",
"subtitle": "Тут ви можете змінити фото, пошту та пароль для свого профілю.",
"section": {
"photo": "Фотографія",
"contacts": "Контакти",
"password": "Пароль"
},
"button": {
"edit": "Змінити",
"save-changes": "Зберегти зміни"
},
"field": {
"current-password": "Поточний пароль",
"new-password": "Новий пароль",
"confirm-password": "Підтвердіть пароль",
"error": "Помилка"
},
"error": {
"invalid-email": "Некоректа пошта",
"password-mismatch": "Паролі не співпадають"
},
"success": {
"update": "Успішно оновлено"
}
}
}
}

0 comments on commit b852bf7

Please sign in to comment.