diff --git a/bun.lock b/bun.lock index 241d00b..8f8b1e1 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "axios": "^1.10.0", "clsx": "^2.1.1", "jwt-decode": "^4.0.0", + "markdown-to-jsx": "^7.7.13", "mime-types": "^3.0.1", "motion": "^12.23.12", "next": "15.4.5", @@ -1041,6 +1042,8 @@ "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "markdown-to-jsx": ["markdown-to-jsx@7.7.13", "", { "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], diff --git a/package.json b/package.json index 12b28c9..0cbddbd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "axios": "^1.10.0", "clsx": "^2.1.1", "jwt-decode": "^4.0.0", + "markdown-to-jsx": "^7.7.13", "mime-types": "^3.0.1", "motion": "^12.23.12", "next": "15.4.5", diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index 40cabfc..230c74c 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -13,6 +13,11 @@ import { useChangePassword } from "@/lib/mutations/session"; import { useGetUserInfo } from "@/lib/queries/session"; import z from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useDictionary } from "@/providers/dictionary-provider"; +import { useState } from "react"; +import { useChangePreference } from "@/lib/mutations/preferences"; +import { useGetUserPreference } from "@/lib/queries/preferences"; +import CustomCombobox from "@/components/combobox"; interface IInputLineProps extends React.InputHTMLAttributes { label: string; @@ -57,35 +62,75 @@ function InputLine({ ); } -const formSchema = z - .object({ - current_password: z - .string() - .min(12, { message: "The password should have at least 12 characters" }) - .max(72, { - message: "The password should be smaller than 72 characters", - }), - password: z - .string() - .min(12, { message: "The password should have at least 12 characters" }) - .max(72, { - message: "The password should be smaller than 72 characters", - }), - password_confirmation: z - .string() - .min(12, { message: "The password should have at least 12 characters" }) - .max(72, { - message: "The password should be smaller than 72 characters", - }), - }) - .refine((data) => data.password === data.password_confirmation, { - path: ["password_confirmation"], - message: "Passwords do not match", - }); +function ComboboxLine({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ -type FormSchema = z.infer; +
{children}
+
+ ); +} export default function Account() { + const dict = useDictionary(); + + const changeLanguage = useChangePreference(); + + const languageOptions = [ + { id: "en-US", name: "English" }, + { id: "pt-PT", name: "Português" }, + ]; + + const [selectedLanguage, setSelectedLanguage] = useState<{ + id: string; + name: string; + } | null>(null); + + const emptyPasswordSchema = z.object({ + current_password: z.literal(""), + password: z.literal(""), + password_confirmation: z.literal(""), + }); + + const updateFormSchema = z + .object({ + current_password: z.string().min(1, "Current password is required"), + password: z + .string() + .min(12, dict.alerts.settings.account.at_least) + .max(72, dict.alerts.settings.account.smaller_than), + password_confirmation: z + .string() + .min(12, dict.alerts.settings.account.at_least) + .max(72, dict.alerts.settings.account.smaller_than), + }) + .refine((data) => data.password === data.password_confirmation, { + message: dict.alerts.settings.account.should_match, + path: ["password_confirmation"], + }); + + const formSchema = z.union([emptyPasswordSchema, updateFormSchema]); + + type FormSchema = z.infer; + const { register, handleSubmit, @@ -95,17 +140,31 @@ export default function Account() { }); const onSubmit: SubmitHandler = (data) => { - changePassword.mutate({ ...data }); + if (selectedLanguage) { + changeLanguage.mutate({ + language: selectedLanguage.id as "en-US" | "pt-PT", + }); + } + if ("password" in data && data.password) { + changePassword.mutate({ + current_password: data.current_password, + password: data.password, + password_confirmation: data.password_confirmation, + }); + } }; + const { data: language } = useGetUserPreference("language"); const user = useGetUserInfo(); const changePassword = useChangePassword(); return ( <> - Account | Pombo - + Imports | Pombo +
+ Pombo | Account +
-

Information

+

+ {dict.settings.sections.account.information} +

-
+ + + option.id === language?.data.language, + )?.name || "Select a language" + } + inputClassName="bg-white px-2 md:p-2.5 rounded-xl w-full bg-transparent text-md outline-none placeholder:text-black/30 invalid:border-red-500 invalid:text-red-600" + /> + @@ -168,18 +258,18 @@ export default function Account() { type="submit" className="bg-primary-400 hover:bg-primary-400/95 mt-6 cursor-pointer rounded-lg px-4 py-2 font-semibold text-white transition-all duration-200 hover:scale-98 md:w-1/3" > - Change Password + {dict.ui.common.buttons.save} {changePassword.isSuccess && (

- Password Changed Successfully + {dict.alerts.settings.account.updated_password}

)} {changePassword.isError && (

- {changePassword.error.message} + {dict.alerts.settings.account.error_password}

)} diff --git a/src/app/(app)/settings/backoffice/configurations/page.tsx b/src/app/(app)/settings/backoffice/configurations/page.tsx index 891477b..669a03f 100644 --- a/src/app/(app)/settings/backoffice/configurations/page.tsx +++ b/src/app/(app)/settings/backoffice/configurations/page.tsx @@ -8,8 +8,11 @@ export default function Configurations() { Configurations | Pombo -

Exchange Period

- +
+
+ +
+
diff --git a/src/app/(app)/settings/backoffice/exports/page.tsx b/src/app/(app)/settings/backoffice/exports/page.tsx index d2e8a0e..c041220 100644 --- a/src/app/(app)/settings/backoffice/exports/page.tsx +++ b/src/app/(app)/settings/backoffice/exports/page.tsx @@ -9,6 +9,7 @@ import { } from "@/lib/queries/backoffice"; import { useGetAllCourses } from "@/lib/queries/courses"; import { ICourse } from "@/lib/types"; +import { useDictionary } from "@/providers/dictionary-provider"; import clsx from "clsx"; import { useState } from "react"; import { twMerge } from "tailwind-merge"; @@ -45,6 +46,7 @@ function formatCourses(courses: ICourse[] | undefined) { } export default function Exports() { + const dict = useDictionary(); const [selectedCourse, setSelectedCourse] = useState<{ id: string; name: string; @@ -80,21 +82,28 @@ export default function Exports() { return ( <> - Exports | Pombo + Pombo | Exports

- Export Blackboard groups + {dict.settings.sections.backoffice.modules.export.title}

-

Trigger the export of Blackboard groups with a few clicks

+

+ {dict.settings.sections.backoffice.modules.export.description} +

-

Courses

+

+ { + dict.settings.sections.backoffice.modules.export.options + .courses + } +

- Shift Groups + { + dict.settings.sections.backoffice.modules.export.options + .shift_groups + } - or + + {dict.ui.common.or} +
diff --git a/src/app/(app)/settings/backoffice/generator/page.tsx b/src/app/(app)/settings/backoffice/generator/page.tsx index 610fa3a..e58c37d 100644 --- a/src/app/(app)/settings/backoffice/generator/page.tsx +++ b/src/app/(app)/settings/backoffice/generator/page.tsx @@ -5,11 +5,13 @@ import CustomSelect from "@/components/select"; import SettingsWrapper from "@/components/settings-wrapper"; import { useGenerateSchedule } from "@/lib/mutations/backoffice"; import { useGetDegrees } from "@/lib/queries/backoffice"; +import { useDictionary } from "@/providers/dictionary-provider"; import clsx from "clsx"; import { useState } from "react"; import { twMerge } from "tailwind-merge"; export default function GenerateSchedule() { + const dict = useDictionary(); const { data: degrees } = useGetDegrees(); const generateSchedule = useGenerateSchedule(); @@ -38,25 +40,48 @@ export default function GenerateSchedule() {
-

Generate new schedule

-

Trigger the schedule generator with a few clicks

+

+ { + dict.settings.sections.backoffice.modules.schedule_generator + .title + } +

+

+ { + dict.settings.sections.backoffice.modules.schedule_generator + .description + } +

-

Degree

+

+ { + dict.settings.sections.backoffice.modules + .schedule_generator.fields.degree + } +

-

Semester

+

+ { + dict.settings.sections.backoffice.modules + .schedule_generator.fields.semester + } +

({ id: `semester-${semester}`, @@ -80,7 +105,10 @@ export default function GenerateSchedule() { ), )} > - Generate Schedule + { + dict.settings.sections.backoffice.modules.schedule_generator + .actions.generate + } {generateSchedule.isPending && ( diff --git a/src/app/(app)/settings/backoffice/imports/page.tsx b/src/app/(app)/settings/backoffice/imports/page.tsx index 81c0d9a..83d8a0c 100644 --- a/src/app/(app)/settings/backoffice/imports/page.tsx +++ b/src/app/(app)/settings/backoffice/imports/page.tsx @@ -9,6 +9,7 @@ import { useImportShiftsByCourses, } from "@/lib/mutations/courses"; import { AuthCheck } from "@/components/auth-check"; +import { useDictionary } from "@/providers/dictionary-provider"; const EXCEL_TYPES = [ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx @@ -27,6 +28,7 @@ interface ImportState { } export default function Imports() { + const dict = useDictionary(); const [importState, setImportState] = useState({ selectedFile: null, type: null, @@ -104,24 +106,32 @@ export default function Imports() { return ( <> - Imports | Pombo + Pombo | Imports
-

Import Data

+

+ {dict.settings.sections.backoffice.modules.import.title} +

- Import Excel files to update the system data. Each import can be - done independently as needed. + {dict.settings.sections.backoffice.modules.import.description}

-

Students by Courses

+

+ { + dict.settings.sections.backoffice.modules.import.types + .shifts_by_courses.title + } +

- Import student enrollment data organized by courses. Use this - when students change courses or new enrollments are added. + { + dict.settings.sections.backoffice.modules.import.types + .shifts_by_courses.description + }

@@ -138,10 +148,17 @@ export default function Imports() {
-

Shifts by Courses

+

+ { + dict.settings.sections.backoffice.modules.import.types + .students_by_courses.title + } +

- Import class schedule data organized by courses. Use this when - schedules change or new shifts are created. + { + dict.settings.sections.backoffice.modules.import.types + .shifts_by_courses.description + }

diff --git a/src/app/(app)/settings/backoffice/jobs/page.tsx b/src/app/(app)/settings/backoffice/jobs/page.tsx index 4f643cd..0ee4d9f 100644 --- a/src/app/(app)/settings/backoffice/jobs/page.tsx +++ b/src/app/(app)/settings/backoffice/jobs/page.tsx @@ -4,11 +4,12 @@ import { AuthCheck } from "@/components/auth-check"; import SettingsWrapper from "@/components/settings-wrapper"; import { useListJobs } from "@/lib/queries/backoffice"; import { IJobProps } from "@/lib/types"; +import { useDictionary } from "@/providers/dictionary-provider"; import clsx from "clsx"; import moment from "moment"; import { twMerge } from "tailwind-merge"; -function getStateStyle(state: string) { +function getStateStyle(state: string, dict: ReturnType) { const STATE_COLORS = { completed: "text-success", executing: "text-celeste", @@ -29,10 +30,23 @@ function getStateStyle(state: string) { cancelled: "cancel", }; + const labels = + dict.settings.sections.backoffice.modules.jobs_monitor.recent_jobs.labels; + const STATE_LABEL = { + completed: labels.completed, + executing: labels.executing, + available: labels.available, + retryable: labels.retryable, + scheduled: labels.scheduled, + discarded: labels.discarded, + cancelled: labels.cancelled, + }; + const textColor = STATE_COLORS[state as keyof typeof STATE_COLORS]; const icon = STATE_ICON[state as keyof typeof STATE_ICON]; + const label = STATE_LABEL[state as keyof typeof STATE_LABEL]; - return { textColor, icon }; + return { textColor, icon, label }; } interface IJobCardProps { @@ -133,6 +147,7 @@ function JobCard({ start_at, completed_at, }: IJobCardProps) { + const dict = useDictionary(); const created = moment(created_at); const start = start_at ? moment(start_at) : null; const end = completed_at ? moment(completed_at) : null; @@ -147,7 +162,7 @@ function JobCard({ : type === "generate" ? "edit_calendar" : "info"; - const { textColor, icon } = getStateStyle(state); + const { textColor, icon, label } = getStateStyle(state, dict); return (
@@ -157,7 +172,7 @@ function JobCard({
-

- {'2. Select "Add to Home Screen"'} + {dict.pwa.instructions.ios.two}

- {"Scroll down in the menu to find this option"} + {dict.pwa.instructions.ios.two_description}

@@ -206,9 +219,11 @@ export function InstallPromptProvider({
-

{'3. Tap "Add"'}

+

+ {dict.pwa.instructions.ios.three} +

- {"Confirm to add the app to your home screen"} + {dict.pwa.instructions.ios.three_description}

@@ -218,20 +233,20 @@ export function InstallPromptProvider({ onClick={() => setClicked(false)} className="cursor-pointer rounded-lg border border-black/10 p-2 shadow-sm transition-all hover:opacity-90 active:scale-95" > - Back + {dict.ui.common.navigation.back}
) : ( <>

- To install this app on your Android device: + {dict.pwa.instructions.android.description}

  • @@ -242,10 +257,10 @@ export function InstallPromptProvider({

- 1. On Chrome, tap the three dots + {dict.pwa.instructions.android.one}

- {"Look for the three dots in the top right corner"} + {dict.pwa.instructions.android.one_description}

@@ -257,10 +272,10 @@ export function InstallPromptProvider({

- {'2. Select "Add to Home Screen"'} + {dict.pwa.instructions.android.two}

- {"Scroll down in the menu to find this option"} + {dict.pwa.instructions.android.two_description}

@@ -271,9 +286,11 @@ export function InstallPromptProvider({
-

{'3. Tap "Install"'}

+

+ {dict.pwa.instructions.android.three} +

- {"Confirm to add the app to your home screen"} + {dict.pwa.instructions.android.three_description}

@@ -283,13 +300,13 @@ export function InstallPromptProvider({ onClick={() => setClicked(false)} className="cursor-pointer rounded-lg border border-black/10 p-2 shadow-sm transition-all hover:opacity-90 active:scale-95" > - Back + {dict.ui.common.navigation.back}
diff --git a/src/internationalization/dictionaries.ts b/src/internationalization/dictionaries.ts new file mode 100644 index 0000000..83c10e8 --- /dev/null +++ b/src/internationalization/dictionaries.ts @@ -0,0 +1,17 @@ +import en from "./dictionaries/en.json"; +import pt from "./dictionaries/pt.json"; + +const dictionaries = { + "en-US": en, + "en-GB": en, + "en-CA": en, + "pt-PT": pt, + "pt-BR": pt, +}; + +export type Language = keyof typeof dictionaries; +export type Dictionary = (typeof dictionaries)[Language]; + +export const getDictionary = (lang: Language): Dictionary => { + return dictionaries[lang] || dictionaries["en-US"]; +}; diff --git a/src/internationalization/dictionaries/en.json b/src/internationalization/dictionaries/en.json new file mode 100644 index 0000000..fc097e4 --- /dev/null +++ b/src/internationalization/dictionaries/en.json @@ -0,0 +1,322 @@ +{ + "ui": { + "common": { + "state": "State", + "or": "or", + "at": "at", + "buttons": { + "save": "Save", + "cancel": "Cancel", + "submit": "Submit", + "edit": "Edit", + "clear": "Clear", + "reset": "Reset", + "create": "Create", + "open": "Open", + "close": "Close", + "got_it": "Got it" + }, + "navigation": { + "today": "Today", + "back": "Back", + "next": "Next" + }, + "states": { + "pending": "Pending", + "completed": "Completed", + "running": "Running", + "failed": "Failed" + }, + "placeholders": { + "select_item": "Select an item", + "select_course": "Select a course", + "select_semester": "Select a semester" + }, + "messages": { + "no_content": "No content to display", + "no_data": "No data available" + }, + "time": { + "created": "Created", + "started": "Started", + "duration": "Duration" + } + }, + "forms": { + "file_uploader": { + "drag": "Drag and drop your file here", + "open": "open a file from your computer", + "max_size": "Maximum Size", + "supports": "Supports" + } + } + }, + "entities": { + "academic": { + "year": ["1st Year", "2nd Year", "3rd Year"], + "semester": ["1st Semester", "2nd Semester"] + } + }, + "calendar": { + "views": { + "month": "Month", + "week": "Week", + "day": "Day", + "feed": "Feed" + }, + "navigation": { + "today": "Today" + } + }, + "options": { + "title": "Options", + "show": "Show options" + }, + "pwa": { + "install": { + "title": "Install app", + "subtitle": "Fast access, better experience.", + "description": "Instala o Pombo no teu ecrã inicial para uma melhor experiência, carregamentos mais rápidos e funcionalidades offline.", + "actions": { + "add_to_home": "Adicionar ao Ecrã Inicial" + } + }, + "instructions": { + "android": { + "description": "To install this app on your Android device:", + "one": "1. On Chrome, tap the three dots", + "one_description": "Look for the three dots in the top right corner", + "two": "2. Select \"Add to Home Screen\"", + "two_description": "Scroll down in the menu to find this option", + "three": "3. Tap \"Install\"", + "three_description": "Confirm to add the app to you home screen" + }, + "ios": { + "description": "To install this app on your iOS device:", + "one": "1. Tap the share button", + "one_description": "Look for the share icon in Safari's toolbar", + "two": "2. Select \"Add to Home Screen\"", + "two_description": "Scroll down in the menu to find this option", + "three": "3. Tap \"Add\"", + "three_description": "Confirm to add the app to your home screen" + } + } + }, + "pages": { + "events": { + "title": "Events", + "description": "Choose the type of events you want to see on your calendar", + "no_content": "There are no events to be displayed", + "actions": { + "show_options": "Show Options" + } + }, + "schedule": { + "title": "Schedule", + "description": "Choose the courses and respective shifts you wish to attend.", + "no_content": "There are no shifts to be displayed", + "actions": { + "edit": "Edit Schedule", + "add": "Add New" + }, + "status": { + "selected": "Selected", + "already_selected": "Already selected", + "available": "Available", + "available_to_add": "Available to add" + } + }, + "exchange": { + "title": "Exchange", + "description": "Manage your shift exchange requests", + "actions": { + "create": "Create a shift exchange request", + "cancel_request": "Cancel request", + "view_state": "See exchange state" + }, + "states": { + "pending": "Pending", + "no_pending": "There are no pending requests", + "is_pending": "Waiting for slot.", + "completed": "Completed", + "no_completed": "There are no completed requests", + "is_completed": "Exchange completed." + }, + "period": { + "title": "Exchange period", + "messages": { + "in_period": "The exchange period ends on", + "has_ended": "The exchange period has ended", + "starts": "The exchange period starts on" + } + }, + "forms": { + "add_request": { + "title": "Create a shift exchange request", + "fields": { + "curricular_unit": "Select the curricular unit", + "shift_type": "Select the shift type", + "preferred_shift": "Select your preferred shift", + "current_shift": "Current shift" + }, + "notification": "You will be notified if the request is fulfilled successfully" + }, + "state_view": { + "title": "Exchange request state", + "description": "Exchange request information", + "fields": { + "curricular_unit": "Curricular Unit", + "shift_type": "Shift Type", + "exchange": "Exchange", + "state": "State" + }, + "info": { + "pending": "Your request is being processed.", + "completed": "This request has been completed." + }, + "disclaimer": { + "pending": "If a suitable exchange is found, you will be notified.", + "completed": "You can view the details of your completed request here." + } + } + }, + "overview": { + "title": "State", + "current_state": "Current state", + "curricular_units": "Your curricular units", + "shifts": "Shifts" + } + } + }, + "settings": { + "title": "Settings", + "sections": { + "account": { + "title": "Account", + "subtitle": "Your Account", + "information": "Information", + "fields": { + "full_name": "Full name", + "email": "Email", + "current_password": "Current password", + "new_password": "New password", + "confirm_password": "Confirm password", + "language": "Language" + }, + "actions": { + "change_password": "Change Password", + "change_language": "Change Language", + "sign_in": "Sign In", + "sign_out": "Sign Out" + } + }, + "backoffice": { + "title": "Backoffice", + "modules": { + "configurations": { + "title": "Configurations", + "exchange": { + "title": "Exchange Period", + "start_date": "Start Date", + "end_date": "End Date" + } + }, + "import": { + "title": "Import Data", + "description": "Import Excel files to update the system data. Each import can be done independently as needed.", + "types": { + "students_by_courses": { + "title": "Students by Courses", + "description": "Import student enrollment data organized by courses. Use this when students change courses or new enrollments are added." + }, + "shifts_by_courses": { + "title": "Shifts by Courses", + "description": "Import class schedule data organized by courses. Use this when schedules change or new shifts are created." + } + } + }, + "export": { + "title": "Export Blackboard groups", + "description": "Trigger the export of Blackboard groups with a few clicks", + "options": { + "courses": "Curricular Units", + "shift_groups": "Shift Groups", + "group_enrollments": "Group Enrollments" + } + }, + "jobs_monitor": { + "title": "Jobs Monitor", + "description": "Track the progress and state of imports and exports in real time", + "recent_jobs": { + "title": "Recent Jobs", + "no_jobs": "There are no recent jobs on record", + "types": { + "import": { + "shifts_by_courses": "Import shifts by courses", + "students_by_courses": "Import students by courses" + }, + "export": { + "shifts_by_courses": "Export shifts by courses", + "students_by_courses": "Export students by courses" + } + }, + "labels": { + "completed": "Completed", + "executing": "Executing", + "available": "Available", + "retryable": "Retryable", + "scheduled": "Programmed", + "discarded": "Discarded", + "cancelled": "Cancelled" + } + } + }, + "statistics": { + "title": "Statistics", + "description": "View and analyze course statistics", + "courses": "Courses", + "shift_statistics": { + "title": "Shift Statistics", + "no_content": "No statistics available for the selected course." + }, + "overall_statistics": { + "title": "Overall Shifts Statistics", + "no_content": "No statistics available for the selected course." + } + }, + "schedule_generator": { + "title": "Generate new schedule", + "description": "Trigger the schedule generator with a few clicks", + "fields": { + "degree": "Degree", + "semester": "Semester" + }, + "actions": { + "generate": "Generate Schedule" + } + } + } + } + } + }, + "alerts": { + "exchange_period": { + "end_before_start": "End date must be after start date.", + "start_in_year": "Start date must be in the year 2025.", + "end_in_year": "End date must be in the year 2025.", + "updated_successfully": "Exchange period updated successfully!", + "problem_updating": "There was a problem updating the exchange period." + }, + "settings": { + "account": { + "at_least": "The password should have at least 12 characters.", + "smaller_than": "Ther password should be smaller than 72 characters.", + "should_match": "Passwords do not match.", + "updated_password": "Password successfully updated.", + "error_password": "There was an error while trying to update your password.", + "updated_language": "Language successfully updated.", + "error_language": "There was an error while trying to update your password." + } + } + } +} diff --git a/src/internationalization/dictionaries/pt.json b/src/internationalization/dictionaries/pt.json new file mode 100644 index 0000000..ba1fe34 --- /dev/null +++ b/src/internationalization/dictionaries/pt.json @@ -0,0 +1,322 @@ +{ + "ui": { + "common": { + "state": "Estado", + "at": "às", + "or": "ou", + "buttons": { + "save": "Gravar", + "cancel": "Cancelar", + "submit": "Submeter", + "edit": "Editar", + "clear": "Limpar", + "reset": "Resetar", + "create": "Criar", + "open": "Abrir", + "close": "Fechar", + "got_it": "Entendi" + }, + "navigation": { + "today": "Hoje", + "back": "Voltar", + "next": "Próximo" + }, + "states": { + "pending": "Pendente", + "completed": "Completo", + "running": "A correr", + "failed": "Falhou" + }, + "placeholders": { + "select_item": "Seleciona um item", + "select_course": "Seleciona um curso", + "select_semester": "Seleciona um semestre" + }, + "messages": { + "no_content": "Sem conteúdo para mostrar", + "no_data": "Sem dados disponíveis" + }, + "time": { + "created": "Criado", + "started": "Começou", + "duration": "Duração" + } + }, + "forms": { + "file_uploader": { + "drag": "Arraste e solte aqui o teu ficheiro", + "open": "abra o ficheiro do teu computador", + "max_size": "Tamanho Máximo", + "supports": "Suporta" + } + } + }, + "entities": { + "academic": { + "year": ["1º Ano", "2º Ano", "3º Ano"], + "semester": ["1º Semestre", "2º Semestre"] + } + }, + "calendar": { + "views": { + "month": "Mês", + "week": "Semana", + "day": "Dia", + "feed": "Feed" + }, + "navigation": { + "today": "Hoje" + } + }, + "options": { + "title": "Opções", + "show": "Mostrar opções" + }, + "pwa": { + "install": { + "title": "Instalar aplicação", + "subtitle": "Melhor acesso, melhor experiência.", + "description": "Instala o **Pombo** no teu ecrã inicial para uma melhor experiência, carregamentos mais rápidos e funcionalidades offline.", + "actions": { + "add_to_home": "Adicionar ao Ecrã Inicial" + } + }, + "instructions": { + "android": { + "description": "Para instalares esta aplicação no teu dispositivo Android:", + "one": "1. No Chrome, toca nos três pontos", + "one_description": "Procura os três pontos no canto superior direito", + "two": "2. Seleciona \"Adicionar ao Ecrã Inicial\"", + "two_description": "Desliza o menu para baixo até encontrares esta opção", + "three": "3. Toca em \"Instalar\"", + "three_description": "Confirma para adicionar a aplicação ao teu ecrã inicial" + }, + "ios": { + "description": "Para instalares esta aplicação no teu dispositivo iOS:", + "one": "1. Toca no botão de partilha", + "one_description": "Procura o ícone de partilha na barra de ferramentas do Safari", + "two": "2. Seleciona \"Adicionar ao Ecrã Inicial\"", + "two_description": "Desliza o menu para baixo até encontrares esta opção", + "three": "3. Toca em \"Adicionar\"", + "three_description": "Confirma para adicionar a aplicação ao teu ecrã inicial" + } + } + }, + "pages": { + "events": { + "title": "Eventos", + "description": "Escolhe o tipo de evento que queres ver no teu calendário", + "no_content": "Não há eventos para mostrar", + "actions": { + "show_options": "Mostrar opções" + } + }, + "schedule": { + "title": "Horário", + "description": "Escolhe os cursos e os respetivos turnos que pretendes frequentar.", + "no_content": "Não há turnos para mostrar", + "actions": { + "edit": "Editar Horário", + "add": "Adicionar" + }, + "status": { + "selected": "Selecionado", + "already_selected": "Já selecionados", + "available": "Disponíveis", + "available_to_add": "Disponíveis para adicionar" + } + }, + "exchange": { + "title": "Trocas", + "description": "Gere os teus pedidos de troca de turnos", + "actions": { + "create": "Criar um novo pedido de troca", + "cancel_request": "Cancelar pedido", + "view_state": "Ver estado da troca" + }, + "states": { + "pending": "Pendentes", + "no_pending": "Não há pedidos pendentes", + "is_pending": "À espera de uma vaga.", + "completed": "Completos", + "no_completed": "Não há pedidos completos", + "is_completed": "Troca completa." + }, + "period": { + "title": "Período de trocas", + "messages": { + "in_period": "O período de trocas se encerra em", + "has_ended": "O período de trocas acabou", + "starts": "O período de troca começa em" + } + }, + "forms": { + "add_request": { + "title": "Criar um novo pedido de troca", + "fields": { + "curricular_unit": "Seleciona uma unidade curricular", + "shift_type": "Seleciona o tipo de turno", + "preferred_shift": "Seleciona o turno que queres", + "current_shift": "Turno atual" + }, + "notification": "Você será notificado quando a troca for realizada" + }, + "state_view": { + "title": "Estado do pedido de troca", + "description": "Informação do pedido", + "fields": { + "curricular_unit": "Unidade Curricular", + "shift_type": "Tipo de turno", + "exchange": "Troca", + "state": "Estado" + }, + "info": { + "pending": "O teu pedido está sendo processado.", + "completed": "O teu pedido de troca já foi realizado." + }, + "disclaimer": { + "pending": "Se um turno correspondente for encontrado, você será notificado(a).", + "completed": "Você pode ver os detalhes da tua troca completa aqui." + } + } + }, + "overview": { + "title": "Estado", + "current_state": "Estado atual", + "curricular_units": "Tuas unidades curriculares", + "shifts": "Turnos" + } + } + }, + "settings": { + "title": "Definições", + "sections": { + "account": { + "title": "Conta", + "subtitle": "A tua conta", + "information": "Informação", + "fields": { + "full_name": "Nome completo", + "email": "Email", + "current_password": "Palavra-passe atual", + "new_password": "Nova palavra-passe", + "confirm_password": "Confirma a palavra-passe", + "language": "Idioma" + }, + "actions": { + "change_password": "Alterar Palavra-passe", + "change_language": "Alterar Idioma", + "sign_in": "Iniciar sessão", + "sign_out": "Terminar sessão" + } + }, + "backoffice": { + "title": "Backoffice", + "modules": { + "configurations": { + "title": "Configurações", + "exchange": { + "title": "Período de Troca", + "start_date": "Data de Início", + "end_date": "Data de Encerramento" + } + }, + "import": { + "title": "Importar Dados", + "description": "Importa ficheiros Excel para atualizar os dados do sistema. Cada importação pode ser feita independentemente.", + "types": { + "students_by_courses": { + "title": "Alunos por Unidades Curriculares", + "description": "Importa dados de matrículas de alunos organizados por cursos. Usar quando os alunos mudarem de curso ou novas matrículas forem adicionadas." + }, + "shifts_by_courses": { + "title": "Turnos por Unidades Curriculares", + "description": "Importe dados de horários de aulas organizados por cursos. Usar quando os horários forem alterados ou novos turnos forem criados." + } + } + }, + "export": { + "title": "Exportar grupos da Blackboard", + "description": "Acione a exportação dos grupos da Blackboard em alguns cliques", + "options": { + "courses": "Unidades Curriculares", + "shift_groups": "Grupos de Turnos", + "group_enrollments": "Inscrições de Grupo" + } + }, + "jobs_monitor": { + "title": "Monitor de Jobs", + "description": "Acompanha o progresso e o estado das importações e exportações em tempo real", + "recent_jobs": { + "title": "Jobs Recentes", + "no_jobs": "Não há jobs recentes registados", + "types": { + "import": { + "shifts_by_courses": "Importar turnos por unidades curriculares", + "students_by_courses": "Importar alunos por unidades curriculares" + }, + "export": { + "shifts_by_courses": "Exportar turnos por unidades curriculares", + "students_by_courses": "Exportar alunos por unidades curriculares" + } + }, + "labels": { + "completed": "Completo", + "executing": "Executando", + "available": "Disponível", + "retryable": "Repetível", + "scheduled": "Programado", + "discarded": "Discartado", + "cancelled": "Cancelado" + } + } + }, + "statistics": { + "title": "Estatísticas", + "description": "Visualiza e analisa estatísticas das unidades curriculares", + "courses": "Unidades Curriculares", + "shift_statistics": { + "title": "Estatísticas dos Turnos", + "no_content": "Sem estatísticas para a UC selecionada" + }, + "overall_statistics": { + "title": "Estatísticas Gerais dos Turnos", + "no_content": "Sem estatísticas para a UC selecionada" + } + }, + "schedule_generator": { + "title": "Gerar novo horário", + "description": "Acione o gerador com alguns cliques", + "fields": { + "degree": "Curso", + "semester": "Semestre" + }, + "actions": { + "generate": "Gerar Horário" + } + } + } + } + } + }, + "alerts": { + "exchange_period": { + "end_before_start": "A data de início deve ser antes da data de encerramento.", + "start_in_year": "A data de início deve ser no ano de 2025.", + "end_in_year": "A data de encerramento deve ser no ano de 2025.", + "updated_successfully": "A período de trocas foi atualizado com sucesso!", + "problem_updating": "Ocorreu um erro ao atualizar o período de trocas." + }, + "settings": { + "account": { + "at_least": "A palavra-passe deve conter pelo menos 12 caracteres", + "smaller_than": "A palavra-passe deve ser menor que 72 caracteres", + "should_match": "As palavras-passe não coincidem", + "updated_password": "A palavra-passe foi atualizada.", + "error_password": "Houve um erro ao tentar atualizar a tua palavra-passe.", + "updated_language": "O idioma foi atualizado com sucesso.", + "error_language": "Houve um erro ao tentar atualizar o teu idioma." + } + } + } +} diff --git a/src/lib/mutations/preferences.ts b/src/lib/mutations/preferences.ts new file mode 100644 index 0000000..92c80eb --- /dev/null +++ b/src/lib/mutations/preferences.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { changePreference } from "../preferences"; + +export function useChangePreference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: changePreference, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["preferences"] }); + }, + }); +} diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts new file mode 100644 index 0000000..222c041 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,9 @@ +import { api } from "./api"; + +export async function getUserPreference(preference: string) { + return await api.get(`/preferences/${preference}`); +} + +export async function changePreference(data: { language: "en-US" | "pt-PT" }) { + return await api.put(`/preferences`, data); +} diff --git a/src/lib/queries/preferences.ts b/src/lib/queries/preferences.ts new file mode 100644 index 0000000..b11b15e --- /dev/null +++ b/src/lib/queries/preferences.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUserPreference } from "../preferences"; + +export function useGetUserPreference(preference: string) { + return useQuery({ + queryKey: ["preferences", preference], + queryFn: () => getUserPreference(preference), + }); +} diff --git a/src/providers/dictionary-provider.tsx b/src/providers/dictionary-provider.tsx new file mode 100644 index 0000000..77b8d36 --- /dev/null +++ b/src/providers/dictionary-provider.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { Dictionary, Language } from "@/internationalization/dictionaries"; +import { getDictionary } from "@/internationalization/dictionaries"; +import { useGetUserInfo } from "@/lib/queries/session"; +import { useGetUserPreference } from "@/lib/queries/preferences"; + +export type DictionaryLanguage = Language; + +interface DictionaryContextData { + dictionary: Dictionary; + language: DictionaryLanguage; + setLanguage?: (language: DictionaryLanguage) => void; +} + +const DictionaryContext = createContext( + undefined, +); + +export function usePreferredLanguage(): DictionaryLanguage { + const { data: language } = useGetUserPreference("language"); + return language?.data.language; +} + +export function getBrowserLanguage(): DictionaryLanguage { + if (typeof navigator !== "undefined" && navigator.language) { + return navigator.language as DictionaryLanguage; + } + return "en-US"; +} + +export function DictionaryProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [language, setLanguage] = useState("en-US"); + + const user = useGetUserInfo(); + const preferredLanguage = usePreferredLanguage(); + const setHtmlLang = (lang: DictionaryLanguage) => { + document.documentElement.lang = lang.slice(0, 2); + }; + + useEffect(() => { + try { + if (user && preferredLanguage) { + setLanguage(preferredLanguage); + } else { + setLanguage(getBrowserLanguage()); + } + } catch { + setLanguage("en-US"); + } + setHtmlLang(language); + }, [user, preferredLanguage, language]); + + const dictionary = getDictionary(language); + return ( + + {children} + + ); +} + +export function useDictionary() { + const context = useContext(DictionaryContext); + if (!context) { + throw new Error("useDictionary must be used within a DictionaryProvider"); + } + return context.dictionary; +} + +export function useLanguage() { + const context = useContext(DictionaryContext); + if (!context) { + throw new Error("useLanguage must be used within a DictionaryProvider"); + } + return context.language; +}