From 3e3a9c59073971453f0fe5a9151a97206165554e Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Tue, 29 Jul 2025 19:15:09 +0100 Subject: [PATCH 01/11] feat: internationalization --- .storybook/main.ts | 21 ++--- .storybook/preview.ts | 1 - eslint.config.mjs | 2 +- postcss.config.mjs | 2 +- src/app/globals.css | 14 ++-- src/app/layout.tsx | 11 ++- src/components/Card.tsx | 6 +- src/components/input.tsx | 31 ++++--- src/components/label.tsx | 30 +++---- src/contexts/dictionary-provider.tsx | 82 +++++++++++++++++++ src/internationalization/dictionaries.ts | 14 ++++ src/internationalization/dictionaries/en.json | 0 src/internationalization/dictionaries/pt.json | 0 src/stories/Button.stories.ts | 26 +++--- src/stories/Button.tsx | 14 ++-- src/stories/Card.stories.ts | 16 ++-- src/stories/Configure.mdx | 44 +++++----- src/stories/Input.stories.ts | 22 ++--- src/stories/Label.stories.ts | 32 ++++---- src/stories/ToggleSwitch.stories.ts | 28 +++---- src/stories/button.css | 2 +- 21 files changed, 253 insertions(+), 145 deletions(-) create mode 100644 src/contexts/dictionary-provider.tsx create mode 100644 src/internationalization/dictionaries.ts create mode 100644 src/internationalization/dictionaries/en.json create mode 100644 src/internationalization/dictionaries/pt.json diff --git a/.storybook/main.ts b/.storybook/main.ts index 84a2abc..0e68624 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,21 +1,16 @@ import type { StorybookConfig } from "@storybook/nextjs-vite"; const config: StorybookConfig = { - "stories": [ - "../src/**/*.mdx", - "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" - ], - "addons": [ + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ "@chromatic-com/storybook", "@storybook/addon-docs", - "@storybook/addon-a11y" + "@storybook/addon-a11y", ], - "framework": { - "name": "@storybook/nextjs-vite", - "options": {} + framework: { + name: "@storybook/nextjs-vite", + options: {}, }, - "staticDirs": [ - "../public" - ] + staticDirs: ["../public"], }; -export default config; \ No newline at end of file +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index c7c9b14..dc161b6 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,3 @@ - import "../src/app/globals.css"; import type { Preview } from "@storybook/nextjs-vite"; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1674939..8002882 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), - ...storybook.configs["flat/recommended"] + ...storybook.configs["flat/recommended"], ]; export default eslintConfig; diff --git a/postcss.config.mjs b/postcss.config.mjs index 1af86d5..4de2724 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: {"@tailwindcss/postcss": {}}, + plugins: { "@tailwindcss/postcss": {} }, }; export default config; diff --git a/src/app/globals.css b/src/app/globals.css index a980576..30ff08f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,17 +47,17 @@ } @theme { - --color-primary: #EE7749; - --color-light: #FFFFFF; + --color-primary: #ee7749; + --color-light: #ffffff; --color-dark: #000000; - --color-muted: #FAFAFA; + --color-muted: #fafafa; --color-university: #971318; - --color-danger: #FF5E79; - --color-success: #2EDB51; + --color-danger: #ff5e79; + --color-success: #2edb51; - --font-jamjuree: var(--font-jamjuree) + --font-jamjuree: var(--font-jamjuree); } input[type="number"] { -moz-appearance: textfield; -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 772b169..691d0aa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,13 +1,14 @@ import type { Metadata } from "next"; import { Bai_Jamjuree } from "next/font/google"; import "./globals.css"; +import { DictionaryProvider } from "@/contexts/dictionary-provider"; const jamjuree = Bai_Jamjuree({ subsets: ["latin"], variable: "--font-jamjuree", weight: ["200", "300", "400", "500", "600", "700"], - display: "swap" -}) + display: "swap", +}); export const metadata: Metadata = { title: "Create Next App", @@ -21,10 +22,8 @@ export default function RootLayout({ }>) { return ( - - {children} + + {children} ); diff --git a/src/components/Card.tsx b/src/components/Card.tsx index ce798d3..9663fc8 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -5,10 +5,12 @@ interface ICardProps { function Card({ children, className }: ICardProps) { return ( -
+
{children}
); } -export default Card; \ No newline at end of file +export default Card; diff --git a/src/components/input.tsx b/src/components/input.tsx index ea9fdc7..c5863ec 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -2,22 +2,33 @@ interface IInputProps extends React.InputHTMLAttributes { center_text?: boolean; } -export default function Input({ placeholder, type, disabled, className, name, value, center_text, min, max, onChange, ...rest }: IInputProps) { - - const textAlignment = center_text ? 'text-center' : 'text-left'; +export default function Input({ + placeholder, + type, + disabled, + className, + name, + value, + center_text, + min, + max, + onChange, + ...rest +}: IInputProps) { + const textAlignment = center_text ? "text-center" : "text-left"; return ( - - ) -} \ No newline at end of file + ); +} diff --git a/src/components/label.tsx b/src/components/label.tsx index f85e0af..ed0f6b4 100644 --- a/src/components/label.tsx +++ b/src/components/label.tsx @@ -4,36 +4,36 @@ interface ILabelProps { children: ReactNode; disabled?: boolean; htmlFor?: string; - size?: 'small' | 'medium' | 'large'; + size?: "small" | "medium" | "large"; onClick?: () => void; } -const Label = ({ - children, - disabled = false, +const Label = ({ + children, + disabled = false, htmlFor, - size = 'medium', + size = "medium", onClick, }: ILabelProps) => { const getSizeClasses = () => { switch (size) { - case 'small': - return 'text-xs'; - case 'large': - return 'text-base'; + case "small": + return "text-xs"; + case "large": + return "text-base"; default: - return 'text-sm'; + return "text-sm"; } }; - const colorClass = disabled ? 'text-gray-400' : 'text-gray-700'; - const cursorClass = disabled ? 'cursor-not-allowed' : 'cursor-pointer'; + const colorClass = disabled ? "text-gray-400" : "text-gray-700"; + const cursorClass = disabled ? "cursor-not-allowed" : "cursor-pointer"; return ( diff --git a/src/contexts/dictionary-provider.tsx b/src/contexts/dictionary-provider.tsx new file mode 100644 index 0000000..6557e0d --- /dev/null +++ b/src/contexts/dictionary-provider.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { Dictionary, Language } from "@/internationalization/dictionaries"; +import { getDictionary } from "@/internationalization/dictionaries"; + +export type DictionaryLanguage = Language; + +interface DictionaryContextData { + dictionary: Dictionary; + language: DictionaryLanguage; +} + +const DictionaryContext = createContext( + undefined, +); + +/* FIXME: Check this function when the api is available */ +export async function PreferedLanguage(): Promise { + const data = await fetch("https://api/preferences/language"); + if (!data.ok) { + throw new Error("Failed to fetch preferred language"); + } + const { language } = await data.json(); + return language as DictionaryLanguage; +} + +export function getBrowserLanguage(): DictionaryLanguage { + if (typeof navigator !== "undefined" && navigator.language) { + return navigator.language as DictionaryLanguage; + } + return "en-US"; +} + +export function DictionaryProvider({ + children, + language: propLanguage, +}: { + children: React.ReactNode; + language?: DictionaryLanguage; +}) { + const [language, setLanguage] = useState( + propLanguage || "en-US", + ); + + useEffect(() => { + if (!propLanguage) { + (async () => { + try { + const preferredLanguage = await PreferedLanguage(); + setLanguage(preferredLanguage); + } catch { + setLanguage(getBrowserLanguage()); + } + })(); + } + }, [propLanguage]); + + 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; +} diff --git a/src/internationalization/dictionaries.ts b/src/internationalization/dictionaries.ts new file mode 100644 index 0000000..48c7bc6 --- /dev/null +++ b/src/internationalization/dictionaries.ts @@ -0,0 +1,14 @@ +import en from "./dictionaries/en.json"; +import pt from "./dictionaries/pt.json"; + +const dictionaries = { + "en-US": en, + "pt-PT": 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..e69de29 diff --git a/src/internationalization/dictionaries/pt.json b/src/internationalization/dictionaries/pt.json new file mode 100644 index 0000000..e69de29 diff --git a/src/stories/Button.stories.ts b/src/stories/Button.stories.ts index 577870f..0218e29 100644 --- a/src/stories/Button.stories.ts +++ b/src/stories/Button.stories.ts @@ -1,22 +1,22 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import { fn } from 'storybook/test'; +import { fn } from "storybook/test"; -import { Button } from './Button'; +import { Button } from "./Button"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { - title: 'Example/Button', + title: "Example/Button", component: Button, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: 'centered', + layout: "centered", }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs - tags: ['autodocs'], + tags: ["autodocs"], // More on argTypes: https://storybook.js.org/docs/api/argtypes argTypes: { - backgroundColor: { control: 'color' }, + backgroundColor: { control: "color" }, }, // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args args: { onClick: fn() }, @@ -29,26 +29,26 @@ type Story = StoryObj; export const Primary: Story = { args: { primary: true, - label: 'Button', + label: "Button", }, }; export const Secondary: Story = { args: { - label: 'Button', + label: "Button", }, }; export const Large: Story = { args: { - size: 'large', - label: 'Button', + size: "large", + label: "Button", }, }; export const Small: Story = { args: { - size: 'small', - label: 'Button', + size: "small", + label: "Button", }, }; diff --git a/src/stories/Button.tsx b/src/stories/Button.tsx index d96916c..0773d46 100644 --- a/src/stories/Button.tsx +++ b/src/stories/Button.tsx @@ -1,4 +1,4 @@ -import './button.css'; +import "./button.css"; export interface ButtonProps { /** Is this the principal call to action on the page? */ @@ -6,7 +6,7 @@ export interface ButtonProps { /** What background color to use */ backgroundColor?: string; /** How large should the button be? */ - size?: 'small' | 'medium' | 'large'; + size?: "small" | "medium" | "large"; /** Button contents */ label: string; /** Optional click handler */ @@ -16,16 +16,20 @@ export interface ButtonProps { /** Primary UI component for user interaction */ export const Button = ({ primary = false, - size = 'medium', + size = "medium", backgroundColor, label, ...props }: ButtonProps) => { - const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + const mode = primary + ? "storybook-button--primary" + : "storybook-button--secondary"; return (
)} +
+ {d.calendar.day} +
+
{/* User dropdown */}
diff --git a/src/internationalization/dictionaries/en.json b/src/internationalization/dictionaries/en.json index f750bd1..d77e2e0 100644 --- a/src/internationalization/dictionaries/en.json +++ b/src/internationalization/dictionaries/en.json @@ -1,3 +1,208 @@ { - "greeting": "Hello" + "events": { + "title": "Events", + "options": { + "title": "Eventos", + "description": "Escolhe o tipo de evento que queres ver no teu calendário", + "no_content": "Não há eventos para mostrar" + } + }, + "schedule": { + "title": "Horário", + + "options": { + "title": "Horário", + "description": "Escolhe os cursos e os respetivos turnos que pretendes frequentar", + "no_content": "Não há turnos para mostrar", + "edit": "Editar Horário", + "selected": "Selecionado", + "add": "Adicionar", + "already_selected": "Já selecionados" + } + }, + "exchange": { + "title": "Trocas", + "create": "Criar um novo pedido de troca", + "see_state": "See exchange state", + "states": { + "pending": "Pendentes", + "completed": "Completos" + }, + "period": { + "title": "Período de trocas", + "description": "O período de trocas se encerra em", + "at": "às" + }, + "state": { + "title": "Current state", + "curricular_units": "Tuas unidades curriculares", + "shifts": "Turnos" + }, + "add_request": { + "title": "Criar um novo pedido de troca", + "select": { + "curricular_unit": "Seleciona uma unidade curricular", + "shift_type": "Seleciona o tipo de turno", + "preferred_shift": "Seleciona o turno que queres", + "placeholder": "Seleciona um item" + }, + "current_shift": "Turno atual", + "preferred_shift": "Turno preferido", + "notification": "Você será notificado quando a troca for realizada", + "submit": "Criar pedido", + "popover": { + "cancel_request": "Cancelar pedido", + "exchange_state": "Ver estado da troca" + } + }, + "exchange_state": { + "title": "Estado do pedido de troca", + "description": "Informação do pedido", + "curricular_unit": "Unidade Curricular", + "shift_type": "Tipo de turno", + "exchange": "Troca", + "state": "Estado", + "pending": "Pendente", + "completed": "Completo", + "info": "O teu pedido está sendo processada", + "notification": "Se um turno correspondente for encontrado, você será notificado(a)" + } + }, + "calendar": { + "today": "Hoje", + "month": "Mês", + "week": "Semana", + "day": "Day" + }, + "options": { + "open_button": "Mostrar opções", + "title": "Opções", + "edit": "Editar", + "clear": "Limpar", + "reset": "Resetar", + "year": { + "first": "1º ano", + "second": "2º ano", + "third": "3º ano" + }, + "semester": { + "first": "1º semestre", + "second": "2º semestre" + } + }, + "settings": { + "title": "Settings", + "account_title": "Account", + "backoffice_title": "Backoffice", + "account": { + "title": "Tua conta", + "information": "Informação", + "full_name": "Nome completo", + "email": "Email", + "current_password": "Palavra-passe atual", + "new_password": "Nova palavra-passe", + "confirm_password": "Confirma a palavra-passe", + "change_password": "Alterar Palavra-passe" + }, + "configurations": { + "exchange": { + "title": "Período de Troca", + "start_date": "Data de Início", + "end_date": "Data de Encerramento", + "submit": "Submit" + } + }, + "export": { + "title": "Exportar grupos da Blackboard", + "description": "Acione a exportação dos grupos da Blackboard em alguns cliques", + "courses": "Unidades Curriculares", + "shift_groups": "Grupos de Turnos", + "group_enrollments": "Inscrições de Grupo" + }, + "jobs_monitor": { + "title": "Monitora os teus Jobs", + "description": "Acompanha o progresso e o estado das importações e exportações em tempo real", + "recent_jobs": { + "title": "Jobs Recentes", + "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" + } + }, + "label": { + "completed": "Completo", + "executing": "Executando", + "available": "Disponível", + "retryable": "Repetível", + "scheduled": "Programado", + "discarded": "Discartado", + "cancelled": "Cancelado" + } + }, + "state": { + "running": "A correr", + "pending": "Pendentes", + "complete": "Completos", + "failed": "Falhas" + }, + "time": { + "created": "Criado", + "started": "Começou", + "duration": "Duração" + } + }, + "statistics": { + "title": "Estatísticas", + "description": "Visualiza e analisa estatísticas das unidades curriculares", + "courses": "Unidades Curriculares", + "select_placeholder": "Seleciona uma unidade curricular", + "shift_statistics": { + "title": "Estatísticas dos Turnos", + "description": "Sem estatísticas para a UC selecionada" + }, + "overall_statistics": { + "title": "Estatísticas Gerais dos Turnos", + "description": "Sem estatísticas para a UC selecionada" + } + }, + "import": { + "title": "Importar Dados", + "description": "Importa ficheiros Excel para atualizar os dados do sistema. Cada importação pode ser feita independentemente.", + "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." + } + }, + "schedule_generator": { + "title": "Gerar novo horário", + "description": "Acione o gerador com alguns cliques", + "degree": { + "title": "Curso", + "select_placeholder": "Seleciona um curso" + }, + "semester": { + "title": "Semester", + "select_placeholder": "Seleciona um semestre" + }, + "submit": "Gerar Horário" + }, + "upload": { + "drag": "Arraste e solte aqui o teu ficheiro", + "or": "ou", + "open": "abra o ficheiro do teu computador", + "max_size": "Tamanho Máximo", + "supports": "Suporta" + } + } } + + diff --git a/src/internationalization/dictionaries/pt.json b/src/internationalization/dictionaries/pt.json index 1a6f72c..5e98220 100644 --- a/src/internationalization/dictionaries/pt.json +++ b/src/internationalization/dictionaries/pt.json @@ -1,3 +1,208 @@ { - "greeting": "Olá" + "events": { + "title": "Events", + "options": { + "title": "Eventos", + "description": "Escolhe o tipo de evento que queres ver no teu calendário", + "no_content": "Não há eventos para mostrar" + } + }, + "schedule": { + "title": "Horário", + + "options": { + "title": "Horário", + "description": "Escolhe os cursos e os respetivos turnos que pretendes frequentar", + "no_content": "Não há turnos para mostrar", + "edit": "Editar Horário", + "selected": "Selecionado", + "add": "Adicionar", + "already_selected": "Já selecionados" + } + }, + "exchange": { + "title": "Trocas", + "create": "Criar um novo pedido de troca", + "see_state": "See exchange state", + "states": { + "pending": "Pendentes", + "completed": "Completos" + }, + "period": { + "title": "Período de trocas", + "description": "O período de trocas se encerra em", + "at": "às" + }, + "state": { + "title": "Current state", + "curricular_units": "Tuas unidades curriculares", + "shifts": "Turnos" + }, + "add_request": { + "title": "Criar um novo pedido de troca", + "select": { + "curricular_unit": "Seleciona uma unidade curricular", + "shift_type": "Seleciona o tipo de turno", + "preferred_shift": "Seleciona o turno que queres", + "placeholder": "Seleciona um item" + }, + "current_shift": "Turno atual", + "preferred_shift": "Turno preferido", + "notification": "Você será notificado quando a troca for realizada", + "submit": "Criar pedido", + "popover": { + "cancel_request": "Cancelar pedido", + "exchange_state": "Ver estado da troca" + } + }, + "exchange_state": { + "title": "Estado do pedido de troca", + "description": "Informação do pedido", + "curricular_unit": "Unidade Curricular", + "shift_type": "Tipo de turno", + "exchange": "Troca", + "state": "Estado", + "pending": "Pendente", + "completed": "Completo", + "info": "O teu pedido está sendo processada", + "notification": "Se um turno correspondente for encontrado, você será notificado(a)" + } + }, + "calendar": { + "today": "Hoje", + "month": "Mês", + "week": "Semana", + "day": "Dia" + }, + "options": { + "open_button": "Mostrar opções", + "title": "Opções", + "edit": "Editar", + "clear": "Limpar", + "reset": "Resetar", + "year": { + "first": "1º ano", + "second": "2º ano", + "third": "3º ano" + }, + "semester": { + "first": "1º semestre", + "second": "2º semestre" + } + }, + "settings": { + "title": "Settings", + "account_title": "Account", + "backoffice_title": "Backoffice", + "account": { + "title": "Tua conta", + "information": "Informação", + "full_name": "Nome completo", + "email": "Email", + "current_password": "Palavra-passe atual", + "new_password": "Nova palavra-passe", + "confirm_password": "Confirma a palavra-passe", + "change_password": "Alterar Palavra-passe" + }, + "configurations": { + "exchange": { + "title": "Período de Troca", + "start_date": "Data de Início", + "end_date": "Data de Encerramento", + "submit": "Submit" + } + }, + "export": { + "title": "Exportar grupos da Blackboard", + "description": "Acione a exportação dos grupos da Blackboard em alguns cliques", + "courses": "Unidades Curriculares", + "shift_groups": "Grupos de Turnos", + "group_enrollments": "Inscrições de Grupo" + }, + "jobs_monitor": { + "title": "Monitora os teus Jobs", + "description": "Acompanha o progresso e o estado das importações e exportações em tempo real", + "recent_jobs": { + "title": "Jobs Recentes", + "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" + } + }, + "label": { + "completed": "Completo", + "executing": "Executando", + "available": "Disponível", + "retryable": "Repetível", + "scheduled": "Programado", + "discarded": "Discartado", + "cancelled": "Cancelado" + } + }, + "state": { + "running": "A correr", + "pending": "Pendentes", + "complete": "Completos", + "failed": "Falhas" + }, + "time": { + "created": "Criado", + "started": "Começou", + "duration": "Duração" + } + }, + "statistics": { + "title": "Estatísticas", + "description": "Visualiza e analisa estatísticas das unidades curriculares", + "courses": "Unidades Curriculares", + "select_placeholder": "Seleciona uma unidade curricular", + "shift_statistics": { + "title": "Estatísticas dos Turnos", + "description": "Sem estatísticas para a UC selecionada" + }, + "overall_statistics": { + "title": "Estatísticas Gerais dos Turnos", + "description": "Sem estatísticas para a UC selecionada" + } + }, + "import": { + "title": "Importar Dados", + "description": "Importa ficheiros Excel para atualizar os dados do sistema. Cada importação pode ser feita independentemente.", + "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." + } + }, + "schedule_generator": { + "title": "Gerar novo horário", + "description": "Acione o gerador com alguns cliques", + "degree": { + "title": "Curso", + "select_placeholder": "Seleciona um curso" + }, + "semester": { + "title": "Semester", + "select_placeholder": "Seleciona um semestre" + }, + "submit": "Gerar Horário" + }, + "upload": { + "drag": "Arraste e solte aqui o teu ficheiro", + "or": "ou", + "open": "abra o ficheiro do teu computador", + "max_size": "Tamanho Máximo", + "supports": "Suporta" + } + } } + + diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts new file mode 100644 index 0000000..5f3d256 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,5 @@ +import { api } from "./api" + +export async function getUserPreference(preference: string) { + return await api.get(`/preferences/${preference}`) +} \ No newline at end of file diff --git a/src/lib/queries/preferences.ts b/src/lib/queries/preferences.ts new file mode 100644 index 0000000..1b0abd5 --- /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) + }) +} \ No newline at end of file diff --git a/src/contexts/dictionary-provider.tsx b/src/providers/dictionary-provider.tsx similarity index 64% rename from src/contexts/dictionary-provider.tsx rename to src/providers/dictionary-provider.tsx index 57455c5..d0d6897 100644 --- a/src/contexts/dictionary-provider.tsx +++ b/src/providers/dictionary-provider.tsx @@ -3,8 +3,8 @@ import { createContext, useContext, useEffect, useState } from "react"; import type { Dictionary, Language } from "@/internationalization/dictionaries"; import { getDictionary } from "@/internationalization/dictionaries"; -import { api } from "@/lib/api"; import { useGetUserInfo } from "@/lib/queries/session"; +import { useGetUserPreference } from "@/lib/queries/preferences"; export type DictionaryLanguage = Language; @@ -18,19 +18,14 @@ const DictionaryContext = createContext( undefined, ); -export async function PreferedLanguage(): Promise { - const response = await api.get("/auth/preferences/language"); - console.log(response); - if (!response) { - throw new Error("Failed to fetch preferred language"); - } - const { language } = response.data; - - return language as DictionaryLanguage; +export function usePreferredLanguage(): DictionaryLanguage { + const { data: language} = useGetUserPreference("language"); + return language?.data.language; } export function getBrowserLanguage(): DictionaryLanguage { if (typeof navigator !== "undefined" && navigator.language) { + console.log("NAVIGATOr",navigator.language) return navigator.language as DictionaryLanguage; } return "en-US"; @@ -38,34 +33,26 @@ export function getBrowserLanguage(): DictionaryLanguage { export function DictionaryProvider({ children, - language: propLanguage, }: { children: React.ReactNode; - language?: DictionaryLanguage; }) { - const [language, setLanguage] = useState( - propLanguage || "en-US", - ); + const [language, setLanguage] = useState("en-US"); const user = useGetUserInfo(); + const preferredLanguage = usePreferredLanguage(); useEffect(() => { - if (!propLanguage) { - (async () => { - try { - if (user) { - const preferredLanguage = await PreferedLanguage(); - setLanguage(preferredLanguage); - } else { - setLanguage(getBrowserLanguage()); - } - } catch { - setLanguage("en-US"); + try { + if (user && preferredLanguage) { + setLanguage(preferredLanguage); + } else { + setLanguage(getBrowserLanguage()); } - })(); + } catch { + setLanguage("en-US"); } - }, [propLanguage, user]); - + }, [user, preferredLanguage]); + console.log("FINAL", language) const dictionary = getDictionary(language); return ( From 38202f010bc882e2ae90c958029ce3b82d60b90d Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 27 Sep 2025 17:18:38 +0100 Subject: [PATCH 06/11] feat: dictionaries --- src/app/(app)/settings/account/page.tsx | 29 +- .../backoffice/configurations/page.tsx | 8 +- .../settings/backoffice/exports/page.tsx | 20 +- .../settings/backoffice/generator/page.tsx | 23 +- .../settings/backoffice/imports/page.tsx | 23 +- .../(app)/settings/backoffice/jobs/page.tsx | 65 ++- .../settings/backoffice/statistics/page.tsx | 53 ++- src/app/layout.tsx | 4 +- src/components/animated-options-section.tsx | 14 +- src/components/calendar-options.tsx | 54 ++- src/components/calendar/toolbar.tsx | 11 +- src/components/exchange-period-form.tsx | 92 ++-- .../exchange/add-exchange-content.tsx | 16 +- src/components/exchange/cards-section.tsx | 16 +- .../exchange/exchange-state-content.tsx | 37 +- src/components/exchange/main-section.tsx | 17 +- src/components/exchange/side-section.tsx | 22 +- src/components/exchange/utils/card.tsx | 29 +- src/components/exchange/utils/listbox.tsx | 5 +- src/components/file-uploader.tsx | 20 +- src/components/navbar.tsx | 45 +- src/components/sidebar-settings.tsx | 41 +- src/components/user-dropdown.tsx | 8 +- src/internationalization/dictionaries/en.json | 439 ++++++++++-------- src/internationalization/dictionaries/pt.json | 436 +++++++++-------- src/lib/preferences.ts | 6 +- src/lib/queries/preferences.ts | 14 +- src/providers/dictionary-provider.tsx | 21 +- 28 files changed, 956 insertions(+), 612 deletions(-) diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index d6fca66..00b6cf3 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -13,6 +13,7 @@ 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"; interface IInputLineProps extends React.InputHTMLAttributes { label: string; @@ -93,7 +94,7 @@ export default function Account() { } = useForm({ resolver: zodResolver(formSchema), }); - + const dict = useDictionary(); const onSubmit: SubmitHandler = (data) => { changePassword.mutate({ ...data }); }; @@ -123,11 +124,13 @@ export default function Account() {
-

Information

+

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

@@ -168,7 +177,7 @@ 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.settings.sections.account.actions.change_password} {changePassword.isSuccess && ( diff --git a/src/app/(app)/settings/backoffice/configurations/page.tsx b/src/app/(app)/settings/backoffice/configurations/page.tsx index 417bb71..eca93f9 100644 --- a/src/app/(app)/settings/backoffice/configurations/page.tsx +++ b/src/app/(app)/settings/backoffice/configurations/page.tsx @@ -1,6 +1,7 @@ import { AuthCheck } from "@/components/auth-check"; import ExchangePeriodForm from "@/components/exchange-period-form"; import SettingsWrapper from "@/components/settings-wrapper"; +import { useDictionary } from "@/providers/dictionary-provider"; import { Metadata } from "next"; export const metadata: Metadata = { @@ -11,8 +12,11 @@ export default function Configurations() { return ( -

Exchange Period

- +
+
+ +
+
); diff --git a/src/app/(app)/settings/backoffice/exports/page.tsx b/src/app/(app)/settings/backoffice/exports/page.tsx index a75181a..2c7a713 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; @@ -85,14 +87,18 @@ export default function Exports() {
-

Export Blackboard groups

-

Trigger the export of Blackboard groups with a few clicks

+

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

+

{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 85de399..bf15bbd 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(); @@ -36,25 +38,34 @@ 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}`, @@ -78,7 +89,7 @@ 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 a3724b6..7d45c2b 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, @@ -107,19 +109,21 @@ export default function 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}

@@ -136,10 +140,11 @@ 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 0aa93ff..0b17acc 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,22 @@ 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 +146,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 +161,7 @@ function JobCard({ : type === "generate" ? "edit_calendar" : "info"; - const { textColor, icon } = getStateStyle(state); + const { textColor, icon, label } = getStateStyle(state, dict); return (
@@ -157,7 +171,7 @@ function JobCard({
-
+
-
-
+ + ); } diff --git a/src/app/(app)/settings/backoffice/configurations/page.tsx b/src/app/(app)/settings/backoffice/configurations/page.tsx index f7e48cd..c33dffb 100644 --- a/src/app/(app)/settings/backoffice/configurations/page.tsx +++ b/src/app/(app)/settings/backoffice/configurations/page.tsx @@ -4,14 +4,17 @@ import SettingsWrapper from "@/components/settings-wrapper"; export default function Configurations() { return ( - - -
-
- -
-
-
-
+ <> + Exports | Pombo + + +
+
+ +
+
+
+
+ ); } diff --git a/src/app/(app)/settings/backoffice/exports/page.tsx b/src/app/(app)/settings/backoffice/exports/page.tsx index df70a2c..1e5f8e7 100644 --- a/src/app/(app)/settings/backoffice/exports/page.tsx +++ b/src/app/(app)/settings/backoffice/exports/page.tsx @@ -81,8 +81,9 @@ export default function Exports() { const validCourse = selectedCourse !== null; return ( - + <> Pombo | Exports +
diff --git a/src/app/(app)/settings/backoffice/generator/page.tsx b/src/app/(app)/settings/backoffice/generator/page.tsx index bd03bc8..e58c37d 100644 --- a/src/app/(app)/settings/backoffice/generator/page.tsx +++ b/src/app/(app)/settings/backoffice/generator/page.tsx @@ -41,35 +41,35 @@ export default function GenerateSchedule() {

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

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

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

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

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

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

@@ -77,11 +77,11 @@ export default function GenerateSchedule() {

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

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

({ id: `semester-${semester}`, @@ -106,9 +106,9 @@ export default function GenerateSchedule() { )} > { - dict.settings.sections.backoffice.modules.schedule_generator - .actions.generate - } + 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 bf05407..bae76cd 100644 --- a/src/app/(app)/settings/backoffice/imports/page.tsx +++ b/src/app/(app)/settings/backoffice/imports/page.tsx @@ -105,6 +105,8 @@ export default function Imports() { }; return ( + <> + Pombo | Imports
diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 9fc28b9..dffb01c 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -296,7 +296,7 @@ function MobileDropdown({ currentPage }: { currentPage: string }) { download - Install app + {dict.pwa.install.title} )}
-

Install App

+

+ {dict.pwa.install.title} +

- Faster access, better experience. + {dict.pwa.install.subtitle}

@@ -149,8 +154,14 @@ export function InstallPromptProvider({ {!clicked ? ( <>

- Install Pombo on your home screen for a - better experience, faster loading and offline features. + + {dict.pwa.install.description} +

) : isIOS ? ( @@ -177,10 +188,10 @@ export function InstallPromptProvider({

- 1. Tap the share button + {dict.pwa.instructions.ios.one}

- {"Look for the share icon in Safari's toolbar"} + {dict.pwa.instructions.ios.one_description}

@@ -192,10 +203,10 @@ export function InstallPromptProvider({

- {'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 +217,9 @@ 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 +229,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 +253,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 +268,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 +282,9 @@ 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 +294,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/en.json b/src/internationalization/dictionaries/en.json index baea70e..fc097e4 100644 --- a/src/internationalization/dictionaries/en.json +++ b/src/internationalization/dictionaries/en.json @@ -13,7 +13,8 @@ "reset": "Reset", "create": "Create", "open": "Open", - "close": "Close" + "close": "Close", + "got_it": "Got it" }, "navigation": { "today": "Today", @@ -71,6 +72,36 @@ "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", diff --git a/src/internationalization/dictionaries/pt.json b/src/internationalization/dictionaries/pt.json index b03981c..ba1fe34 100644 --- a/src/internationalization/dictionaries/pt.json +++ b/src/internationalization/dictionaries/pt.json @@ -13,7 +13,8 @@ "reset": "Resetar", "create": "Criar", "open": "Abrir", - "close": "Fechar" + "close": "Fechar", + "got_it": "Entendi" }, "navigation": { "today": "Hoje", @@ -71,6 +72,36 @@ "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", From 9bfb3b3f5f65dd6d4ada64ffb2630047ff1c99ee Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 27 Sep 2025 22:10:37 +0100 Subject: [PATCH 09/11] format --- .../backoffice/configurations/page.tsx | 2 +- .../settings/backoffice/exports/page.tsx | 133 +++++++++--------- .../settings/backoffice/imports/page.tsx | 50 +++---- src/contexts/install-prompt-provider.tsx | 28 ++-- 4 files changed, 109 insertions(+), 104 deletions(-) diff --git a/src/app/(app)/settings/backoffice/configurations/page.tsx b/src/app/(app)/settings/backoffice/configurations/page.tsx index c33dffb..669a03f 100644 --- a/src/app/(app)/settings/backoffice/configurations/page.tsx +++ b/src/app/(app)/settings/backoffice/configurations/page.tsx @@ -5,7 +5,7 @@ import SettingsWrapper from "@/components/settings-wrapper"; export default function Configurations() { return ( <> - Exports | Pombo + Configurations | Pombo
diff --git a/src/app/(app)/settings/backoffice/exports/page.tsx b/src/app/(app)/settings/backoffice/exports/page.tsx index 1e5f8e7..c041220 100644 --- a/src/app/(app)/settings/backoffice/exports/page.tsx +++ b/src/app/(app)/settings/backoffice/exports/page.tsx @@ -83,77 +83,76 @@ export default function Exports() { return ( <> Pombo | Exports - - - -
-
-

- {dict.settings.sections.backoffice.modules.export.title} -

-

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

-
- -
-
-
-

+ + +

+
+

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

+

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

+
+ +
+
+
+

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

+ +
+
+ +
+ + + + {dict.ui.common.or} + + +
-
- -
- - - - {dict.ui.common.or} - - - -
{exportShiftsGroupError && (

diff --git a/src/app/(app)/settings/backoffice/imports/page.tsx b/src/app/(app)/settings/backoffice/imports/page.tsx index bae76cd..83d8a0c 100644 --- a/src/app/(app)/settings/backoffice/imports/page.tsx +++ b/src/app/(app)/settings/backoffice/imports/page.tsx @@ -107,31 +107,31 @@ export default function Imports() { return ( <> Pombo | Imports - - -

-
-

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

-

- {dict.settings.sections.backoffice.modules.import.description} -

-
+ + +
+
+

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

+

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

+

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

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

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

@@ -149,15 +149,15 @@ export default function Imports() {

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

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

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

diff --git a/src/contexts/install-prompt-provider.tsx b/src/contexts/install-prompt-provider.tsx index 87bee07..f4cf0ea 100644 --- a/src/contexts/install-prompt-provider.tsx +++ b/src/contexts/install-prompt-provider.tsx @@ -6,7 +6,7 @@ import { useContext, useEffect, useState } from "react"; import { motion } from "motion/react"; import { createContext } from "react"; import { useDictionary } from "@/providers/dictionary-provider"; -import Markdown from "markdown-to-jsx" +import Markdown from "markdown-to-jsx"; interface InstallPromptContextData { open: boolean; @@ -154,14 +154,16 @@ export function InstallPromptProvider({ {!clicked ? ( <>

- - {dict.pwa.install.description} - + + {dict.pwa.install.description} +

-

{dict.pwa.instructions.ios.three}

+

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

{dict.pwa.instructions.ios.three_description}

@@ -282,7 +286,9 @@ export function InstallPromptProvider({
-

{dict.pwa.instructions.android.three}

+

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

{dict.pwa.instructions.android.three_description}

From 5a7caa9389bf06dfcc0cfd797e86c3f7968b2bd1 Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Fri, 3 Oct 2025 18:07:42 +0100 Subject: [PATCH 10/11] feat: change document lang dynamically --- src/providers/dictionary-provider.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/providers/dictionary-provider.tsx b/src/providers/dictionary-provider.tsx index d08a30e..77b8d36 100644 --- a/src/providers/dictionary-provider.tsx +++ b/src/providers/dictionary-provider.tsx @@ -39,6 +39,9 @@ export function DictionaryProvider({ const user = useGetUserInfo(); const preferredLanguage = usePreferredLanguage(); + const setHtmlLang = (lang: DictionaryLanguage) => { + document.documentElement.lang = lang.slice(0, 2); + }; useEffect(() => { try { @@ -50,7 +53,8 @@ export function DictionaryProvider({ } catch { setLanguage("en-US"); } - }, [user, preferredLanguage]); + setHtmlLang(language); + }, [user, preferredLanguage, language]); const dictionary = getDictionary(language); return ( From 34a2e3dc4dec21ab223b4d9befd4cc91415aad7d Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 4 Oct 2025 12:17:04 +0100 Subject: [PATCH 11/11] fix: form structure --- src/app/(app)/settings/account/page.tsx | 112 +++++++++++++++++++----- src/components/combobox.tsx | 3 + src/components/language-form.tsx | 71 --------------- 3 files changed, 92 insertions(+), 94 deletions(-) delete mode 100644 src/components/language-form.tsx diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index fef84c9..230c74c 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -14,7 +14,10 @@ import { useGetUserInfo } from "@/lib/queries/session"; import z from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useDictionary } from "@/providers/dictionary-provider"; -import LanguageForm from "@/components/language-form"; +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; @@ -59,35 +62,73 @@ function InputLine({ ); } +function ComboboxLine({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ + +
{children}
+
+ ); +} + export default function Account() { const dict = useDictionary(); - const formSchema = z + 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(12, { message: dict.alerts.settings.account.at_least }) - .max(72, { - message: dict.alerts.settings.account.smaller_than, - }), + current_password: z.string().min(1, "Current password is required"), password: z .string() - .min(12, { message: dict.alerts.settings.account.at_least }) - .max(72, { - message: dict.alerts.settings.account.smaller_than, - }), + .min(12, dict.alerts.settings.account.at_least) + .max(72, dict.alerts.settings.account.smaller_than), password_confirmation: z .string() - .min(12, { message: dict.alerts.settings.account.at_least }) - .max(72, { - message: dict.alerts.settings.account.smaller_than, - }), + .min(12, dict.alerts.settings.account.at_least) + .max(72, dict.alerts.settings.account.smaller_than), }) .refine((data) => data.password === data.password_confirmation, { - path: ["password_confirmation"], message: dict.alerts.settings.account.should_match, + path: ["password_confirmation"], }); + const formSchema = z.union([emptyPasswordSchema, updateFormSchema]); + type FormSchema = z.infer; const { @@ -99,9 +140,21 @@ 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(); @@ -143,13 +196,26 @@ export default function Account() { label="Email" value={user.data?.email || "user email"} /> -
- -
+ + 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" + /> + - {dict.settings.sections.account.actions.change_password} + {dict.ui.common.buttons.save} {changePassword.isSuccess && ( diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx index d8ff638..478fd98 100644 --- a/src/components/combobox.tsx +++ b/src/components/combobox.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { twMerge } from "tailwind-merge"; interface ICustomCombobox { + autocomplete?: string; items: IItemProps[]; selectedItem: IItemProps | null; setSelectedItem: (item: IItemProps | null) => void; @@ -22,6 +23,7 @@ interface ICustomCombobox { } export default function CustomCombobox({ + autocomplete, items, selectedItem, setSelectedItem, @@ -46,6 +48,7 @@ export default function CustomCombobox({ >
item?.name || ""} diff --git a/src/components/language-form.tsx b/src/components/language-form.tsx deleted file mode 100644 index d34175f..0000000 --- a/src/components/language-form.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useDictionary } from "@/providers/dictionary-provider"; -import { useChangePreference } from "@/lib/mutations/preferences"; -import CustomCombobox from "@/components/combobox"; -import { useState } from "react"; -import Label from "@/components/label"; -import { twMerge } from "tailwind-merge"; -import clsx from "clsx"; - -function LanguageForm() { - const dict = useDictionary(); - const [selectedLanguage, setSelectedLanguage] = useState<{ - id: string; - name: string; - } | null>(null); - - const changeLanguage = useChangePreference(); - - const languageOptions = [ - { id: "en-US", name: "English" }, - { id: "pt-PT", name: "Português" }, - ]; - - const handleSave = () => { - if (selectedLanguage) { - changeLanguage.mutate({ - language: selectedLanguage.id as "en-US" | "pt-PT", - }); - } - }; - - return ( -
-
- - - -
- - - - {changeLanguage.isSuccess && ( -

- {dict.alerts.settings.account.updated_language} -

- )} - - {changeLanguage.isError && ( -

- {dict.alerts.settings.account.error_language} -

- )} -
- ); -} - -export default LanguageForm;