From b852bf7b18b89048b936604b25870c72220a381f Mon Sep 17 00:00:00 2001 From: Markusplay Date: Fri, 27 Dec 2024 23:10:05 +0200 Subject: [PATCH] add ui for settings page --- src/actions/group.actions.ts | 2 +- src/actions/settings.actions.ts | 41 ++++ .../[locale]/(private)/profile-picture.tsx | 34 +++- src/app/[locale]/(private)/settings/page.tsx | 30 +++ .../(private)/settings/settings-form.tsx | 184 ++++++++++++++++++ src/components/ui/locale-switch.tsx | 2 +- src/messages/en.json | 29 ++- src/messages/uk.json | 27 +++ 8 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 src/actions/settings.actions.ts create mode 100644 src/app/[locale]/(private)/settings/page.tsx create mode 100644 src/app/[locale]/(private)/settings/settings-form.tsx diff --git a/src/actions/group.actions.ts b/src/actions/group.actions.ts index 36bf251a..8768f031 100644 --- a/src/actions/group.actions.ts +++ b/src/actions/group.actions.ts @@ -20,4 +20,4 @@ export async function searchByGroupName(search: string): Promise { } catch (error) { throw new Error('Error loading groups'); } -} \ No newline at end of file +} diff --git a/src/actions/settings.actions.ts b/src/actions/settings.actions.ts new file mode 100644 index 00000000..1bffa1a6 --- /dev/null +++ b/src/actions/settings.actions.ts @@ -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.'); + } +} diff --git a/src/app/[locale]/(private)/profile-picture.tsx b/src/app/[locale]/(private)/profile-picture.tsx index 2e770b21..d0ae2c90 100644 --- a/src/app/[locale]/(private)/profile-picture.tsx +++ b/src/app/[locale]/(private)/profile-picture.tsx @@ -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'); + const sizeClass = sizeMap[variant]; + const iconSize = iconSizeMap[variant]; + return ( - - + + - + ); diff --git a/src/app/[locale]/(private)/settings/page.tsx b/src/app/[locale]/(private)/settings/page.tsx new file mode 100644 index 00000000..6f5bb49f --- /dev/null +++ b/src/app/[locale]/(private)/settings/page.tsx @@ -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 ( + +
+ {t('title')} + {t('subtitle')} + +
+
+ ); +} diff --git a/src/app/[locale]/(private)/settings/settings-form.tsx b/src/app/[locale]/(private)/settings/settings-form.tsx new file mode 100644 index 00000000..132accbf --- /dev/null +++ b/src/app/[locale]/(private)/settings/settings-form.tsx @@ -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(null); + + const [user] = useLocalStorage('user'); + const [file, setFile] = useState(null); + const [previewImage, setPreviewImage] = useState(user?.photo); + + const handleFileUpload = (e: React.ChangeEvent) => { + 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; + + 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 ( + + + {t('section.photo')} +
+ + + +
+
+ + {t('section.contacts')} + ( + + E-mail + + + + + + )} + /> + + {t('section.password')} + ( + + {t('field.current-password')} + + + )} + /> + ( + + {t('field.new-password')} + + + )} + /> + ( + + {t('field.confirm-password')} + + {form.formState.errors.confirmPassword && ( + {form.formState.errors.confirmPassword?.message} + )} + + )} + /> + {form.formState.errors.root?.message} + + + +
+
+ ); +} diff --git a/src/components/ui/locale-switch.tsx b/src/components/ui/locale-switch.tsx index 07aefb4e..b6a70318 100644 --- a/src/components/ui/locale-switch.tsx +++ b/src/components/ui/locale-switch.tsx @@ -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 'Перейти на українську 🇺🇦'; } diff --git a/src/messages/en.json b/src/messages/en.json index a50085ec..022570aa 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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", @@ -152,6 +152,33 @@ "contacts": { "title": "Contacts", "content": "

Contact details

Bureau office address: Ukraine, Kyiv, 14-v Polytechnic St., Building 13, 4th floor, room 25

Social networks

KPI on GitHub

Facebook

Twitter

Instagram

Support service

If you have any questions, you can contact the support service:

Complaints and suggestions form

Email" + }, + + "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" + } } } } diff --git a/src/messages/uk.json b/src/messages/uk.json index 66db2013..f56b2f3c 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -153,6 +153,33 @@ "title": "Контакти", "header": "Контакти", "content": "

Контактнi данi

Адреса конструкторського бюро: Україна, м. Київ, вул. Політехнічна 14-в, корпус 13, 4 поверх, 25 кабінет

Соцiальнi мережi

КПI у GitHub

Facebook

Twitter

Instagram

Служба підтримки

Якщо у вас є питання, ви можете звернутися до служби підтримки:

Форма скарг i пропозицiй

Email" + }, + + "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": "Успішно оновлено" + } } } }