diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..84a93cdd02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +.env.local +*.log +coverage/ +.vite/ +cypress/videos/ +cypress/screenshots/ +storybook-static/ diff --git a/README.md b/README.md index 39dedd60ec..3de38904bf 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,76 @@ -# Dynamox Developer Challenges +> 📋 **Solução deste desafio: [SOLUTION.md](./SOLUTION.md)** — como executar, arquitetura e decisões. +> +> TL;DR: `bun install && bun run dev` → http://localhost:3000/data -## About Dynamox +--- -[Dynamox](https://dynamox.net/) is a high-tech firm specializing in vibration analysis and industrial asset condition monitoring. Our expert team develops comprehensive hardware and software solutions, encompassing firmware, mobile applications (Android and iOS), and full-stack cloud native applications. +# Desafio de Desenvolvedor Front-end da Dynamox -With our proficiency in signal processing for vibration and acoustics, we deliver advanced and precise monitoring systems. We are committed to optimizing operational efficiency and facilitating proactive maintenance through our innovative technology and integrated solutions. +A equipe de desenvolvimento front-end da Dynamox apresenta o seguinte desafio: -## Positions +**Utilizando React e TypeScript, desenvolva uma aplicação de dashboard robusta e intuitiva que permita analisar os dados coletados por nossos sensores.** -We are looking for developers who are passionate about learning, growing, and contributing to our team. You will play a key role in our development efforts, working on a variety of projects and collaborating with different teams to build and improve our solutions. +--- -We value flexibility and collaboration, hence we provide opportunities for you to lend your skills to other teams when required. Join us on this exciting journey as we revolutionize our digital platforms. Currently we are particularly interested in individuals who can identify with one of the following role descriptions: +Lembre-se de que o desafio visa reproduzir um ambiente onde você possa demonstrar suas habilidades. -### Junior Software Developer +Para orientar seu processo de desenvolvimento, forneceremos alguns requisitos. Não é obrigatório atender a todos os requisitos para enviar sua implementação. Quanto mais requisitos você implementar, mais elementos teremos para avaliar suas habilidades e conhecimentos. -With limited experience, assists in coding, testing, and stabilizing systems under supervision. Communicates with immediate team members and solves straightforward problems with guidance. Should display a willingness to learn and grow professionally. This is an individual contributor role. +Use seu bom senso para priorizar tarefas de acordo com o tempo disponível. Sinta-se à vontade para fazer quaisquer suposições que considere necessárias para concluir a tarefa. -### Mid-level Software Developer +## Requisitos Funcionais e Histórias de Usuário -With a certain level of proven experience, contributes to software development, solves moderate problems, and starts handling ambiguous situations with minimal guidance. Communicates with the broader team and engages in code reviews and documentation. This role also includes supporting junior engineers and commitment to continuous learning. This is an individual contributor role. +Na indústria de manutenção, a análise de vibração desempenha um papel fundamental: ela utiliza grandezas físicas, como aceleração e velocidade, para identificar indícios que ajudem a prever a ocorrência de falhas ou degradações em máquinas. -### Senior-level Software Developer +A página que exibe essas informações no DynaPredict, nossa plataforma de monitoramento de condições de ativos, foi projetada de forma muito semelhante a [este modelo no Figma](https://www.figma.com/file/QxUZkTUIzQA7cvyiMvVyxK/Front-end---Teste?type=design&node-id=1001%3A3&mode=design&t=JLnbGmQJcSlnYYE2-1). -With vast experience, enhances software development, leading complex system development and ambiguous situation handling. Tackles intricate problems and mentors junior and mid-level engineers. Champions coding standards, project strategy, and technology adoption. Communicates across teams, influencing technical and non-technical stakeholders. This individual contributor role blends technical expertise with leadership, focusing on innovation, mentorship, and strategic contributions to the development process. +Utilize o Figma como referência para construir a interface e desenvolver as seguintes histórias de usuário: -## Challenges Full-Stack +1 - Histórias de Usuário -- [ ] [01 - Dynamox Full-Stack Node.js React Developer Challenge](./full-stack-challenge.md) -- [ ] [02 - Dynamox Full-Stack C# React Developer Challenge](./full-stack-csharp-react-challenge.md) - -## Challenges Front-End +1. [ ] Como usuário, quero acessar a rota `/data` da minha aplicação e visualizar uma tela contendo um cabeçalho com informações sobre a máquina e alguns gráficos de série temporal. -- [ ] [01 - Dynamox Front-end React Developer Challenge Marketing Teams](./front-end-challenge-v1.md) -- [ ] [02 - Dynamox Front-end React Developer Challenge Product Teams](./front-end-challenge-v2.md) +1. [ ] Como usuário, quero visualizar 3 gráficos de série temporal para as seguintes métricas: aceleração, velocidade e temperatura. Cada série temporal deve apresentar um eixo horizontal para o tempo e um eixo vertical para a magnitude da métrica. -## Challenges DevOps +1. [ ] Como usuário, quero que os dados que alimentarão esses gráficos sejam buscados sempre que eu acessar a rota `/data`. Utilize os dados disponíveis em [Responses](./response-challenge-v2.json) como _mock_ e use um pacote como o [json-server](https://www.npmjs.com/package/json-server) para criar uma API REST. -- [ ] [01 - Dynamox DevOps Developer Challenge Foundation Teams](./dev-sec-fin-ops-challenge-v1/README.md) +1. [ ] Como usuário, quero passar o mouse sobre um ponto de um gráfico e ver uma linha de referência vertical (_crosshair_) marcando os _timestamps_ correspondentes em todos os gráficos de séries temporais, acompanhada de um _tooltip_ descrevendo aquele ponto. [Confira](https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/demo/synchronized-charts) um exemplo dessa funcionalidade. -## Challenges Mobile +2 - Requisitos técnicos -- [ ] [01 - Dynamox Kotlin Multiplatform Developer Challenge](./kotlin-multiplatform-challenge.md) -- [ ] [02 - Dynamox Android Developer Challenge](./android-challenge.md) -- [ ] [03 - Dynamox iOS Developer Challenge](./ios-challenge.md) +1. [ ] Utilize TypeScript. +1. [ ] Utilize React. +1. [ ] Utilize Redux para gerenciar estados globais. [Confira](https://redux-toolkit.js.org/introduction/getting-started) como começar. +1. [ ] Utilize Redux Saga para lidar com efeitos colaterais assíncronos. [Confira](https://redux-saga.js.org/docs/introduction/GettingStarted) como começar. +1. [ ] Utilize Vite para o _build_ da aplicação. [Confira](https://vitejs.dev/guide/) como começar. +1. [ ] Utilize Material UI 5 para estilizar a aplicação. [Confira](https://mui.com/material-ui/getting-started/) como começar. +1. [ ] Utilize Highcharts, Plotly, D3 ou qualquer biblioteca similar para exibir os gráficos. [Confira](https://www.highcharts.com/docs/index) como começar. +1. [ ] Garanta a lógica de negócio e o comportamento corretos por meio de testes unitários automatizados. -## Challenge Back-End -- [ ] [01 - Dynamox Back-End Time Series ](./back-end-challenge-v1.md) +Incentivamos o uso das ferramentas da nossa _stack_ de _frontend_ para que o desafio se assemelhe às nossas tarefas do dia a dia. -## Challenge QA -- [ ] [01- Dynamox QA Challenge](./qa-challenge.md) +3 - Bônus -
+1. [ ] Utilize Storybook para documentação. [Confira](https://storybook.js.org/docs) como começar. +1. [ ] Adicione testes E2E com Cypress. [Confira](https://learn.cypress.io/) como começar. +1. [ ] Faça o _deploy_ da sua aplicação em um provedor de nuvem e forneça o link da aplicação em execução. ## Critérios de Avaliação -**Good luck! We look forward to reviewing your submission.** 🚀 +Cada um dos itens acima será avaliado como "Não Implementado", "Implementado com Problemas", "Implementado" ou "Implementado com Excelência". Para avaliar diferentes perfis e experiências, esperamos que candidatos a níveis mais seniores demonstrem uma compreensão mais profunda dos requisitos e implementem uma quantidade maior deles dentro do mesmo prazo. + +De modo geral, avaliaremos os seguintes pontos: + +1. [ ] Qualquer pessoa deve conseguir seguir as instruções e executar a aplicação. +1. [ ] O código do front-end está integrado com sucesso a uma API REST simulada (fake API). +1. [ ] As histórias foram implementadas de acordo com os requisitos funcionais. +1. [ ] Habilidades de resolução de problemas e capacidade de lidar com ambiguidades. +1. [ ] Qualidade, legibilidade e manutenibilidade do código. +1. [ ] O código está bem organizado e documentado. +1. [ ] O layout da aplicação é responsivo. + +## Pronto para começar os desafios? + +- Faça um _fork_ deste repositório para a sua própria conta no GitHub. +- Crie uma nova _branch_ usando seu primeiro e último nome. Por exemplo: `caroline-oliveira`. +- Após concluir o desafio, crie um _pull request_ para este repositório (https://github.com/dynamox-s-a/js-ts-full-stack-test) apontando para a _branch_ `main`. +- Receberemos uma notificação sobre o seu _pull request_, analisaremos sua solução e entraremos em contato com você. +
diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000000..c9c18a443f --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,144 @@ +# Solução — Desafio Front-end Dynamox + +Dashboard de análise de vibração: rota `/data` com cabeçalho da máquina e três séries temporais +(aceleração, velocidade e temperatura) com crosshair e tooltip sincronizados entre os gráficos. + +## Como executar + +Pré-requisito: [Bun](https://bun.sh) 1.3+ (`curl -fsSL https://bun.sh/install | bash`). + +```bash +bun install +bun run dev +``` + +Isso sobe os dois apps em paralelo: + +| Serviço | URL | +| -------- | ------------------------------- | +| Frontend | http://localhost:3000/data | +| API | http://localhost:3333/api | +| Docs API | http://localhost:3333/api/docs | + +Para rodar separadamente: `bun run dev:backend` e `bun run dev:frontend`. + +Verificação: + +```bash +bun run test # 59 testes (12 backend + 47 frontend) +bun run typecheck +bun run build +``` + +Variáveis de ambiente são opcionais — os padrões já apontam para o ambiente local. +Veja `apps/*/.env.example`. + +## Estrutura + +``` +├── packages/ +│ └── contracts/ # fonte da verdade: rotas oRPC + schemas Zod +└── apps/ + ├── backend/ # implementa o contrato, lê os mocks JSON + └── frontend/ # consome o contrato (Vite + React + Redux Saga) +``` + +O pacote `contracts` fica **fora de `apps/`** de propósito: ele não é uma aplicação, é o +acoplamento entre as duas. O backend implementa esse contrato e o frontend o consome, então +tipos e validação fluem dos dois lados sem geração de código e sem duplicar tipo nenhum. +Mudar um schema quebra o build de quem estiver desalinhado — que é exatamente o objetivo. + +### Backend + +Clean Architecture com injeção de dependência por _factory function_: + +``` +presentation → application → domain ← infrastructure +``` + +`domain/` e `application/` são puros: não importam `@orpc/*` nem conhecem JSON, HTTP ou o +formato do mock. oRPC vive só em `presentation/`, e a leitura dos arquivos só em +`infrastructure/`. É por isso que os casos de uso são testáveis mockando apenas o repositório. + +Trocar os mocks por um banco real significa trocar a implementação do repositório e a linha +correspondente no `container.ts` — nenhuma regra de negócio muda. + +### Frontend + +- **Estado global (Redux Toolkit):** máquina selecionada, período e séries carregadas. +- **Efeitos assíncronos (Redux Saga):** `watchFetchMachines` e `watchFetchMeasurements`, ambos + com `takeLatest` — trocar de máquina rápido cancela as buscas anteriores, então uma resposta + atrasada nunca sobrescreve a seleção atual. +- **Transporte:** todo o acesso à API passa por `lib/orpc-client.ts`. Nenhum componente conhece + URL ou header. +- **Filtros na URL:** máquina e período vivem em `?machine=MCH-003&period=7d`, então a tela é + compartilhável e sobrevive ao refresh. +- **UI:** tema e componentes do [Minimals](https://minimals.cc) v7.5.0 portados para Vite + (o kit original é Next.js). + +## Decisões e trade-offs + +**Um único fetch por acesso à rota.** A rota `/data` busca as 7 séries do sensor de uma vez, em +vez de uma requisição por gráfico. São 181 pontos por série — o custo de trazer tudo é menor que +o de três _round-trips_, e garante que os três gráficos exibam exatamente a mesma janela de +tempo (requisito do crosshair sincronizado). + +**Margem fixa nos gráficos.** Todos os gráficos usam `marginLeft` fixo. Não é estética: a +sincronização localiza o ponto de cada gráfico pela coordenada X em pixels. Deixando o +Highcharts dimensionar cada margem pelo tamanho do próprio rótulo do eixo Y (`0.1` contra `15`), +as áreas de plot ficam deslocadas entre si e o mesmo X aponta para instantes diferentes — o +crosshair mente. Isso apareceu em teste manual: os gráficos reportavam 02:03 e 07:03 para o +mesmo cursor. + +**Período ancorado na última leitura.** Os dados do mock terminam em dezembro de 2023. Ancorar +"últimos 7 dias" em `Date.now()` devolveria uma tela vazia, então a âncora é o `lastReadingAt` +da máquina. + +**Filtros hidratados da URL uma única vez.** A sincronização é assimétrica de propósito: a URL +alimenta a store no mount, e só a partir daí a store passa a escrever na URL (`replace`, para +não empilhar histórico a cada clique). Sincronizar nos dois sentidos por efeito parece simétrico, +mas o efeito de escrita roda no mesmo commit com o estado ainda antigo e apaga os filtros do +link antes de eles serem aplicados — foi exatamente o que aconteceu na primeira tentativa. + +**Estatísticas calculadas sobre a janela filtrada.** O "máximo do período" acompanha o recorte +escolhido pelo usuário, não a série inteira — é o número que ele espera ver ao filtrar. + +**Três máquinas, um sensor.** O mock fornecido cobre um único sensor. As três máquinas existem +para exercitar a listagem e a troca de seleção, mas todas leem a mesma série; o ponto de +particionar por máquina está isolado no repositório. + +## Requisitos + +| Requisito | Situação | +| -------------------------------- | ----------------------------------------------------- | +| Rota `/data` com cabeçalho | ✅ | +| 3 gráficos de série temporal | ✅ aceleração (3 eixos), velocidade (3 eixos), temperatura | +| Busca a cada acesso à rota | ✅ | +| Crosshair + tooltip sincronizados| ✅ | +| TypeScript | ✅ modo estrito, sem `any` | +| React | ✅ 19 | +| Redux | ✅ Redux Toolkit | +| Redux Saga | ✅ | +| Vite | ✅ 7 | +| Material UI | ✅ v7 — ver observação abaixo | +| Biblioteca de gráficos | ✅ Highcharts 12 | +| Testes unitários | ✅ 59 testes | +| Layout responsivo | ✅ | +| API REST simulada | ⚠️ oRPC sobre os mocks, no lugar do `json-server` | +| Storybook / Cypress / deploy | ❌ bônus não implementados | + +**Sobre o `json-server`.** O enunciado sugere `json-server` para servir +`response-challenge-v2.json`. A API aqui é um servidor oRPC próprio que lê o mesmo arquivo +(`apps/backend/src/mock/accelerationRms.json`) — nenhum banco de dados externo. A troca entrega +o que o `json-server` não daria: contrato tipado compartilhado com o frontend, validação Zod nas +duas pontas, filtros de verdade (`from`/`to`/`metrics`) e documentação OpenAPI automática. As +rotas continuam REST e podem ser chamadas com `curl`: + +```bash +curl 'localhost:3333/api/machines' +curl 'localhost:3333/api/machines/MCH-001/measurements?metrics[]=temperature' +``` + +**Sobre o Material UI 5.** O enunciado pede MUI 5; a solução usa MUI 7, versão exigida pelo tema +do Minimals v7.5.0 (que depende da API de CSS variables e `colorSchemes`, ausente na v5). +Fixar a v5 significaria abrir mão do kit ou reescrever o tema. diff --git a/android-challenge.md b/android-challenge.md deleted file mode 100644 index bb3168beec..0000000000 --- a/android-challenge.md +++ /dev/null @@ -1,130 +0,0 @@ -# Dynamox Android Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive Android Quiz application in Kotlin. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Kotlin** -- [ ] Use a data persistence mechanism to store players and scores -- [ ] Use Jetpack Compose for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Kotlin Flow/Coroutines** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria - -- Technical capability -- Android knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. \ No newline at end of file diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000000..0d317a73dc --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,2 @@ +PORT=3333 +CORS_ORIGIN=http://localhost:3000 diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000000..072a38e38c --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "@orpc/zod": "catalog:", + "@repo/contracts": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "catalog:" + } +} diff --git a/apps/backend/src/container.ts b/apps/backend/src/container.ts new file mode 100644 index 0000000000..10bb03e815 --- /dev/null +++ b/apps/backend/src/container.ts @@ -0,0 +1,16 @@ +import type { Machine } from '@repo/contracts'; + +import { makeGetMachine, makeListMachines } from './modules/machines/application/list-machines'; +import { makeJsonMachineRepository } from './modules/machines/infrastructure/json-machine-repository'; +import { makeGetMachineMeasurements } from './modules/measurements/application/get-machine-measurements'; +import { makeJsonMeasurementRepository } from './modules/measurements/infrastructure/json-measurement-repository'; + +import machinesMock from './mock/machines.json' with { type: 'json' }; +import seriesMock from './mock/accelerationRms.json' with { type: 'json' }; + +const machineRepository = makeJsonMachineRepository(machinesMock as Machine[]); +const measurementRepository = makeJsonMeasurementRepository(seriesMock); + +export const listMachines = makeListMachines(machineRepository); +export const getMachine = makeGetMachine(machineRepository); +export const getMachineMeasurements = makeGetMachineMeasurements(measurementRepository, machineRepository); diff --git a/apps/backend/src/core/context.ts b/apps/backend/src/core/context.ts new file mode 100644 index 0000000000..045eb330c0 --- /dev/null +++ b/apps/backend/src/core/context.ts @@ -0,0 +1,4 @@ +import { implement } from '@orpc/server'; +import { contracts } from '@repo/contracts'; + +export const pub = implement(contracts); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000000..0fb708df1e --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,36 @@ +import { OpenAPIHandler } from '@orpc/openapi/fetch'; +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'; +import { CORSPlugin } from '@orpc/server/plugins'; +import { ZodSmartCoercionPlugin } from '@orpc/zod'; +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; + +import { router } from './router'; + +const PORT = Number(process.env.PORT ?? 3333); +const CORS_ORIGIN = (process.env.CORS_ORIGIN ?? 'http://localhost:3000').split(','); + +const handler = new OpenAPIHandler(router, { + plugins: [ + new CORSPlugin({ origin: CORS_ORIGIN, allowMethods: ['GET', 'OPTIONS'] }), + new ZodSmartCoercionPlugin(), + new OpenAPIReferencePlugin({ + docsProvider: 'scalar', + docsPath: '/docs', + schemaConverters: [new ZodToJsonSchemaConverter()], + specGenerateOptions: { + info: { title: 'Dynamox Sensors API', version: '1.0.0' }, + }, + }), + ], +}); + +Bun.serve({ + port: PORT, + async fetch(request) { + const { matched, response } = await handler.handle(request, { prefix: '/api' }); + + return matched ? response : Response.json({ message: 'Not found' }, { status: 404 }); + }, +}); + +console.log(`API em http://localhost:${PORT}/api • docs em http://localhost:${PORT}/api/docs`); diff --git a/response-challenge-v2.json b/apps/backend/src/mock/accelerationRms.json similarity index 100% rename from response-challenge-v2.json rename to apps/backend/src/mock/accelerationRms.json diff --git a/apps/backend/src/mock/machines.json b/apps/backend/src/mock/machines.json new file mode 100644 index 0000000000..1b0df0e4a1 --- /dev/null +++ b/apps/backend/src/mock/machines.json @@ -0,0 +1,47 @@ +[ + { + "id": "MCH-001", + "name": "Ventilador de Exaustão 01", + "type": "Motor elétrico", + "manufacturer": "WEG", + "rpm": 1780, + "status": "alert", + "sensor": { + "id": "SNR-8842", + "model": "DynaLogger TcAg", + "serialNumber": "HF-8842-2023", + "position": "Mancal dianteiro" + }, + "lastReadingAt": "2023-12-12T15:02:42.000Z" + }, + { + "id": "MCH-002", + "name": "Bomba Centrífuga 04", + "type": "Bomba", + "manufacturer": "KSB", + "rpm": 3550, + "status": "operational", + "sensor": { + "id": "SNR-9110", + "model": "DynaLogger TcAs", + "serialNumber": "HF-9110-2023", + "position": "Mancal traseiro" + }, + "lastReadingAt": "2023-12-12T15:02:42.000Z" + }, + { + "id": "MCH-003", + "name": "Redutor da Esteira 12", + "type": "Redutor", + "manufacturer": "SEW-Eurodrive", + "rpm": 890, + "status": "operational", + "sensor": { + "id": "SNR-7431", + "model": "DynaLogger TcAg", + "serialNumber": "HF-7431-2023", + "position": "Carcaça" + }, + "lastReadingAt": "2023-12-12T15:02:42.000Z" + } +] diff --git a/apps/backend/src/modules/machines/application/list-machines.ts b/apps/backend/src/modules/machines/application/list-machines.ts new file mode 100644 index 0000000000..3e1a88a962 --- /dev/null +++ b/apps/backend/src/modules/machines/application/list-machines.ts @@ -0,0 +1,11 @@ +import type { Machine } from '@repo/contracts'; + +import type { MachineRepository } from '../domain/machine-repository'; + +export const makeListMachines = (machineRepository: MachineRepository) => async (): Promise => + machineRepository.findAll(); + +export const makeGetMachine = + (machineRepository: MachineRepository) => + async (id: string): Promise => + machineRepository.findById(id); diff --git a/apps/backend/src/modules/machines/domain/machine-repository.ts b/apps/backend/src/modules/machines/domain/machine-repository.ts new file mode 100644 index 0000000000..8dc2b1e914 --- /dev/null +++ b/apps/backend/src/modules/machines/domain/machine-repository.ts @@ -0,0 +1,6 @@ +import type { Machine } from '@repo/contracts'; + +export type MachineRepository = { + findAll(): Promise; + findById(id: string): Promise; +}; diff --git a/apps/backend/src/modules/machines/infrastructure/json-machine-repository.ts b/apps/backend/src/modules/machines/infrastructure/json-machine-repository.ts new file mode 100644 index 0000000000..eba49e4fde --- /dev/null +++ b/apps/backend/src/modules/machines/infrastructure/json-machine-repository.ts @@ -0,0 +1,12 @@ +import type { Machine } from '@repo/contracts'; + +import type { MachineRepository } from '../domain/machine-repository'; + +export const makeJsonMachineRepository = (machines: Machine[]): MachineRepository => ({ + async findAll() { + return machines; + }, + async findById(id) { + return machines.find((machine) => machine.id === id) ?? null; + }, +}); diff --git a/apps/backend/src/modules/machines/presentation/machine-routes.ts b/apps/backend/src/modules/machines/presentation/machine-routes.ts new file mode 100644 index 0000000000..38f4fabec3 --- /dev/null +++ b/apps/backend/src/modules/machines/presentation/machine-routes.ts @@ -0,0 +1,14 @@ +import { ORPCError } from '@orpc/server'; + +import { getMachine, listMachines } from '../../../container'; +import { pub } from '../../../core/context'; + +export const listMachinesRoute = pub.machines.list.handler(async () => ({ data: await listMachines() })); + +export const getMachineRoute = pub.machines.get.handler(async ({ input }) => { + const machine = await getMachine(input.id); + + if (!machine) throw new ORPCError('NOT_FOUND', { message: `Máquina ${input.id} não encontrada` }); + + return machine; +}); diff --git a/apps/backend/src/modules/measurements/application/get-machine-measurements.test.ts b/apps/backend/src/modules/measurements/application/get-machine-measurements.test.ts new file mode 100644 index 0000000000..050fb8f58b --- /dev/null +++ b/apps/backend/src/modules/measurements/application/get-machine-measurements.test.ts @@ -0,0 +1,72 @@ +import type { Machine } from '@repo/contracts'; +import { describe, expect, it } from 'bun:test'; + +import type { MachineRepository } from '../../machines/domain/machine-repository'; +import type { MeasurementRepository } from '../domain/measurement-repository'; +import type { SensorSeries } from '../domain/series'; +import { makeGetMachineMeasurements } from './get-machine-measurements'; + +const machine = { id: 'MCH-001' } as Machine; + +const series: SensorSeries[] = [ + { + id: 'velocityRms/x', + metric: 'velocity', + axis: 'x', + unit: 'mm/s', + points: [ + { datetime: '2023-11-07T00:00:00.000Z', max: 1 }, + { datetime: '2023-11-08T00:00:00.000Z', max: 5 }, + ], + }, + { id: 'temperature', metric: 'temperature', axis: null, unit: '°C', points: [] }, +]; + +const makeSut = (machines: Machine[] = [machine]) => { + const machineRepository: MachineRepository = { + async findAll() { + return machines; + }, + async findById(id) { + return machines.find((item) => item.id === id) ?? null; + }, + }; + + const measurementRepository: MeasurementRepository = { + async findByMachineId() { + return series; + }, + }; + + return makeGetMachineMeasurements(measurementRepository, machineRepository); +}; + +describe('makeGetMachineMeasurements', () => { + it('retorna null quando a máquina não existe', async () => { + await expect(makeSut([])({ machineId: 'inexistente' })).resolves.toBeNull(); + }); + + it('devolve todas as séries com estatísticas quando não há filtro', async () => { + const result = await makeSut()({ machineId: 'MCH-001' }); + + expect(result?.series).toHaveLength(2); + expect(result?.series[0].stats).toEqual({ min: 1, max: 5, avg: 3, last: 5 }); + }); + + it('filtra por grandeza', async () => { + const result = await makeSut()({ machineId: 'MCH-001', metrics: ['temperature'] }); + + expect(result?.series.map((item) => item.id)).toEqual(['temperature']); + }); + + it('recalcula as estatísticas sobre a janela recortada, não sobre a série inteira', async () => { + const result = await makeSut()({ + machineId: 'MCH-001', + metrics: ['velocity'], + to: '2023-11-07T12:00:00.000Z', + }); + + expect(result?.series[0].points).toHaveLength(1); + expect(result?.series[0].stats).toEqual({ min: 1, max: 1, avg: 1, last: 1 }); + }); +}); diff --git a/apps/backend/src/modules/measurements/application/get-machine-measurements.ts b/apps/backend/src/modules/measurements/application/get-machine-measurements.ts new file mode 100644 index 0000000000..2a589ac342 --- /dev/null +++ b/apps/backend/src/modules/measurements/application/get-machine-measurements.ts @@ -0,0 +1,25 @@ +import type { Measurements, MeasurementsQuery } from '@repo/contracts'; + +import type { MachineRepository } from '../../machines/domain/machine-repository'; +import type { MeasurementRepository } from '../domain/measurement-repository'; +import { computeStats, filterPointsByRange } from '../domain/series'; + +export const makeGetMachineMeasurements = + (measurementRepository: MeasurementRepository, machineRepository: MachineRepository) => + async ({ machineId, from, to, metrics }: MeasurementsQuery): Promise => { + const machine = await machineRepository.findById(machineId); + + if (!machine) return null; + + const series = await measurementRepository.findByMachineId(machineId); + + const filtered = series + .filter((item) => !metrics?.length || metrics.includes(item.metric)) + .map((item) => { + const points = filterPointsByRange(item.points, from, to); + + return { ...item, points, stats: computeStats(points) }; + }); + + return { machineId, series: filtered }; + }; diff --git a/apps/backend/src/modules/measurements/domain/measurement-repository.ts b/apps/backend/src/modules/measurements/domain/measurement-repository.ts new file mode 100644 index 0000000000..594d9fc2d1 --- /dev/null +++ b/apps/backend/src/modules/measurements/domain/measurement-repository.ts @@ -0,0 +1,5 @@ +import type { SensorSeries } from './series'; + +export type MeasurementRepository = { + findByMachineId(machineId: string): Promise; +}; diff --git a/apps/backend/src/modules/measurements/domain/series.test.ts b/apps/backend/src/modules/measurements/domain/series.test.ts new file mode 100644 index 0000000000..9ce7c086f4 --- /dev/null +++ b/apps/backend/src/modules/measurements/domain/series.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; + +import { computeStats, filterPointsByRange, parseSeriesId } from './series'; + +describe('parseSeriesId', () => { + it('separa grandeza e eixo das séries do acelerômetro', () => { + expect(parseSeriesId('accelerationRms/x')).toEqual({ metric: 'acceleration', axis: 'x' }); + expect(parseSeriesId('velocityRms/z')).toEqual({ metric: 'velocity', axis: 'z' }); + }); + + it('trata temperatura como grandeza escalar, sem eixo', () => { + expect(parseSeriesId('temperature')).toEqual({ metric: 'temperature', axis: null }); + }); + + it('ignora séries desconhecidas ou com eixo inválido', () => { + expect(parseSeriesId('humidity')).toBeNull(); + expect(parseSeriesId('velocityRms/w')).toBeNull(); + }); +}); + +describe('computeStats', () => { + it('calcula mínimo, máximo, média e última leitura', () => { + const points = [ + { datetime: '2023-11-07T00:00:00.000Z', max: 2 }, + { datetime: '2023-11-08T00:00:00.000Z', max: 6 }, + { datetime: '2023-11-09T00:00:00.000Z', max: 4 }, + ]; + + expect(computeStats(points)).toEqual({ min: 2, max: 6, avg: 4, last: 4 }); + }); + + it('não quebra com série vazia', () => { + expect(computeStats([])).toEqual({ min: 0, max: 0, avg: 0, last: 0 }); + }); +}); + +describe('filterPointsByRange', () => { + const points = [ + { datetime: '2023-11-07T00:00:00.000Z', max: 1 }, + { datetime: '2023-11-08T00:00:00.000Z', max: 2 }, + { datetime: '2023-11-09T00:00:00.000Z', max: 3 }, + ]; + + it('recorta pelo intervalo, incluindo os limites', () => { + const result = filterPointsByRange(points, '2023-11-08T00:00:00.000Z', '2023-11-09T00:00:00.000Z'); + + expect(result.map((point) => point.max)).toEqual([2, 3]); + }); + + it('aceita limite aberto de um lado só', () => { + expect(filterPointsByRange(points, undefined, '2023-11-07T12:00:00.000Z')).toHaveLength(1); + expect(filterPointsByRange(points, '2023-11-08T12:00:00.000Z')).toHaveLength(1); + }); + + it('devolve a série inteira quando não há limites ou eles são inválidos', () => { + expect(filterPointsByRange(points)).toHaveLength(3); + expect(filterPointsByRange(points, 'não é data')).toHaveLength(3); + }); +}); diff --git a/apps/backend/src/modules/measurements/domain/series.ts b/apps/backend/src/modules/measurements/domain/series.ts new file mode 100644 index 0000000000..03de036028 --- /dev/null +++ b/apps/backend/src/modules/measurements/domain/series.ts @@ -0,0 +1,70 @@ +import type { Axis, DataPoint, Metric, SeriesStats } from '@repo/contracts'; + +export type SensorSeries = { + id: string; + metric: Metric; + axis: Axis | null; + unit: string; + points: DataPoint[]; +}; + +export const METRIC_UNITS: Record = { + acceleration: 'g', + velocity: 'mm/s', + temperature: '°C', +}; + +const METRIC_BY_PREFIX: Record = { + accelerationRms: 'acceleration', + velocityRms: 'velocity', + temperature: 'temperature', +}; + +export const parseSeriesId = (id: string): { metric: Metric; axis: Axis | null } | null => { + const [prefix, axis] = id.split('/'); + const metric = METRIC_BY_PREFIX[prefix]; + + if (!metric) return null; + if (!axis) return { metric, axis: null }; + if (axis !== 'x' && axis !== 'y' && axis !== 'z') return null; + + return { metric, axis }; +}; + +export const computeStats = (points: DataPoint[]): SeriesStats => { + if (points.length === 0) return { min: 0, max: 0, avg: 0, last: 0 }; + + const values = points.map((point) => point.max); + const total = values.reduce((sum, value) => sum + value, 0); + + return { + min: Math.min(...values), + max: Math.max(...values), + avg: total / values.length, + last: values[values.length - 1], + }; +}; + +export const filterPointsByRange = (points: DataPoint[], from?: string, to?: string): DataPoint[] => { + const fromTime = toTime(from); + const toTimeValue = toTime(to); + + if (fromTime === null && toTimeValue === null) return points; + + return points.filter((point) => { + const time = new Date(point.datetime).getTime(); + + if (fromTime !== null && time < fromTime) return false; + if (toTimeValue !== null && time > toTimeValue) return false; + + return true; + }); +}; + +const toTime = (value?: string): number | null => { + if (!value) return null; + + const time = new Date(value).getTime(); + + return Number.isNaN(time) ? null : time; +}; diff --git a/apps/backend/src/modules/measurements/infrastructure/json-measurement-repository.ts b/apps/backend/src/modules/measurements/infrastructure/json-measurement-repository.ts new file mode 100644 index 0000000000..a0d31ae3ac --- /dev/null +++ b/apps/backend/src/modules/measurements/infrastructure/json-measurement-repository.ts @@ -0,0 +1,33 @@ +import type { DataPoint } from '@repo/contracts'; + +import type { MeasurementRepository } from '../domain/measurement-repository'; +import { METRIC_UNITS, parseSeriesId, type SensorSeries } from '../domain/series'; + +type RawSeries = { name: string; data: DataPoint[] }; + +export const makeJsonMeasurementRepository = (raw: RawSeries[]): MeasurementRepository => { + const series = toDomain(raw); + + return { + async findByMachineId() { + return series; + }, + }; +}; + +const toDomain = (raw: RawSeries[]): SensorSeries[] => + raw.flatMap((item) => { + const parsed = parseSeriesId(item.name); + + if (!parsed) return []; + + return [ + { + id: item.name, + metric: parsed.metric, + axis: parsed.axis, + unit: METRIC_UNITS[parsed.metric], + points: item.data, + }, + ]; + }); diff --git a/apps/backend/src/modules/measurements/presentation/measurement-routes.ts b/apps/backend/src/modules/measurements/presentation/measurement-routes.ts new file mode 100644 index 0000000000..56f217560c --- /dev/null +++ b/apps/backend/src/modules/measurements/presentation/measurement-routes.ts @@ -0,0 +1,14 @@ +import { ORPCError } from '@orpc/server'; + +import { getMachineMeasurements } from '../../../container'; +import { pub } from '../../../core/context'; + +export const listMeasurementsRoute = pub.measurements.list.handler(async ({ input }) => { + const measurements = await getMachineMeasurements(input); + + if (!measurements) { + throw new ORPCError('NOT_FOUND', { message: `Máquina ${input.machineId} não encontrada` }); + } + + return measurements; +}); diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts new file mode 100644 index 0000000000..0ab5d42edf --- /dev/null +++ b/apps/backend/src/router.ts @@ -0,0 +1,7 @@ +import { getMachineRoute, listMachinesRoute } from './modules/machines/presentation/machine-routes'; +import { listMeasurementsRoute } from './modules/measurements/presentation/measurement-routes'; + +export const router = { + machines: { list: listMachinesRoute, get: getMachineRoute }, + measurements: { list: listMeasurementsRoute }, +}; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000000..040d232335 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + }, + "include": ["src"] +} diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000000..7342bb0853 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3333 diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000000..276145a6f6 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + DynaPredict — Análise de vibração + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000000..f51d784800 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,53 @@ +{ + "name": "frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --port 3000", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/public-sans": "^5.2.7", + "@iconify/react": "^6.0.2", + "@mui/material": "^7.3.2", + "@mui/x-date-pickers": "^8.12.0", + "@orpc/client": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi-client": "catalog:", + "@reduxjs/toolkit": "^2.12.0", + "@repo/contracts": "workspace:*", + "dayjs": "^1.11.18", + "es-toolkit": "^1.39.10", + "highcharts": "^12.4.0", + "highcharts-react-official": "^3.2.2", + "minimal-shared": "^1.1.0", + "react": "catalog:", + "react-dom": "catalog:", + "react-redux": "^9.2.0", + "react-router": "^7.9.1", + "redux-saga": "^1.5.0", + "simplebar-react": "^3.3.2" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.15", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "jsdom": "^26.1.0", + "typescript": "catalog:", + "vite": "^7.1.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:" + } +} diff --git a/apps/frontend/public/assets/icons/empty/ic-content.svg b/apps/frontend/public/assets/icons/empty/ic-content.svg new file mode 100644 index 0000000000..8a420edd44 --- /dev/null +++ b/apps/frontend/public/assets/icons/empty/ic-content.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico new file mode 100644 index 0000000000..45b0186d0d Binary files /dev/null and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/src/components/empty-content/empty-content.tsx b/apps/frontend/src/components/empty-content/empty-content.tsx new file mode 100644 index 0000000000..e667ed8c19 --- /dev/null +++ b/apps/frontend/src/components/empty-content/empty-content.tsx @@ -0,0 +1,115 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TypographyProps } from '@mui/material/Typography'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export type EmptyContentProps = React.ComponentProps<'div'> & { + title?: string; + imgUrl?: string; + filled?: boolean; + sx?: SxProps; + description?: string; + action?: React.ReactNode; + slotProps?: { + img?: BoxProps<'img'>; + title?: TypographyProps; + description?: TypographyProps; + }; +}; + +export function EmptyContent({ + sx, + imgUrl, + action, + filled, + slotProps, + description, + title = 'No data', + ...other +}: EmptyContentProps) { + return ( + + + + {title && ( + + {title} + + )} + + {description && ( + + {description} + + )} + + {action && action} + + ); +} + +// ---------------------------------------------------------------------- + +const ContentRoot = styled('div', { + shouldForwardProp: (prop: string) => !['filled', 'sx'].includes(prop), +})>(({ filled, theme }) => ({ + flexGrow: 1, + height: '100%', + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + padding: theme.spacing(0, 3), + ...(filled && { + borderRadius: Number(theme.shape.borderRadius) * 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.04), + border: `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), +})); diff --git a/apps/frontend/src/components/empty-content/index.ts b/apps/frontend/src/components/empty-content/index.ts new file mode 100644 index 0000000000..026ff327ca --- /dev/null +++ b/apps/frontend/src/components/empty-content/index.ts @@ -0,0 +1 @@ +export * from './empty-content'; diff --git a/apps/frontend/src/components/iconify/classes.ts b/apps/frontend/src/components/iconify/classes.ts new file mode 100644 index 0000000000..6d4a463314 --- /dev/null +++ b/apps/frontend/src/components/iconify/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const iconifyClasses = { + root: createClasses('iconify__root'), +}; diff --git a/apps/frontend/src/components/iconify/icon-sets.ts b/apps/frontend/src/components/iconify/icon-sets.ts new file mode 100644 index 0000000000..b35ac90deb --- /dev/null +++ b/apps/frontend/src/components/iconify/icon-sets.ts @@ -0,0 +1,702 @@ +export default { + /** + * @set Solar icons + */ + 'solar:pen-bold': { + body: '', + }, + 'solar:bed-bold': { + body: '', + }, + 'solar:eye-bold': { + body: '', + }, + 'solar:copy-bold': { + body: '', + }, + 'solar:flag-bold': { + body: '', + }, + 'solar:list-bold': { + body: '', + }, + 'solar:heart-bold': { + body: '', + }, + 'solar:phone-bold': { + body: '', + }, + 'solar:share-bold': { + body: '', + }, + 'solar:inbox-bold': { + body: '', + }, + 'solar:reply-bold': { + body: '', + }, + 'solar:danger-bold': { + body: '', + }, + 'solar:cart-3-bold': { + body: '', + }, + 'solar:import-bold': { + body: '', + }, + 'solar:export-bold': { + body: '', + }, + 'solar:eraser-bold': { + body: '', + }, + 'solar:user-id-bold': { + body: '', + }, + 'solar:restart-bold': { + body: '', + }, + 'solar:forward-bold': { + body: '', + }, + 'solar:inbox-in-bold': { + body: '', + }, + 'solar:download-bold': { + body: '', + }, + 'solar:cup-star-bold': { + body: '', + }, + 'solar:bell-off-bold': { + body: '', + }, + 'solar:cart-plus-bold': { + body: '', + }, + 'solar:bill-list-bold': { + body: '', + }, + 'solar:bell-bing-bold': { + body: '', + }, + 'solar:file-text-bold': { + body: '', + }, + 'solar:user-plus-bold': { + body: '', + }, + 'solar:eye-closed-bold': { + body: '', + }, + 'solar:camera-add-bold': { + body: '', + }, + 'solar:microphone-bold': { + body: '', + }, + 'solar:add-circle-bold': { + body: '', + }, + 'solar:play-circle-bold': { + body: '', + }, + 'solar:info-circle-bold': { + body: '', + }, + 'solar:medical-kit-bold': { + body: '', + }, + 'solar:gallery-add-bold': { + body: '', + }, + 'solar:dumbbell-large-minimalistic-bold': { + body: '', + }, + 'solar:user-rounded-bold': { + body: '', + }, + 'solar:close-circle-bold': { + body: '', + }, + 'solar:check-circle-bold': { + body: '', + }, + 'solar:wad-of-money-bold': { + body: '', + }, + 'solar:shield-check-bold': { + body: '', + }, + 'solar:clock-circle-bold': { + body: '', + }, + 'solar:smartphone-2-bold': { + body: '', + }, + 'solar:gallery-wide-bold': { + body: '', + }, + 'solar:calendar-date-bold': { + body: '', + }, + 'solar:verified-check-bold': { + body: '', + }, + 'solar:trash-bin-trash-bold': { + body: '', + }, + 'solar:danger-triangle-bold': { + body: '', + }, + 'solar:forbidden-circle-bold': { + body: '', + }, + 'solar:videocamera-record-bold': { + body: '', + }, + 'solar:users-group-rounded-bold': { + body: '', + }, + 'solar:printer-minimalistic-bold': { + body: '', + }, + 'solar:stop-circle-bold': { + body: '', + }, + 'solar:settings-bold': { + body: '', + }, + 'solar:volume-bold': { + body: '', + }, + 'solar:volume-loud-bold': { + body: '', + }, + 'solar:monitor-bold': { + body: '', + }, + 'solar:add-folder-bold': { + body: '', + }, + 'solar:tv-bold': { + body: '', + }, + 'solar:box-minimalistic-bold': { + body: '', + }, + 'solar:video-frame-play-horizontal-bold': { + body: '', + }, + 'solar:palette-bold': { + body: '', + }, + 'solar:headphones-round-bold': { + body: '', + }, + 'solar:like-bold': { + body: '', + }, + 'solar:ssd-round-bold': { + body: '', + }, + 'solar:tea-cup-bold': { + body: '', + }, + 'solar:electric-refueling-bold': { + body: '', + }, + 'solar:gamepad-bold': { + body: '', + }, + 'solar:suitcase-tag-bold': { + body: '', + }, + 'solar:case-minimalistic-bold': { + body: '', + }, + 'solar:archive-down-minimlistic-bold': { + body: '', + }, + 'solar:chat-round-dots-bold': { + body: '', + }, + 'solar:letter-bold': { + body: '', + }, + 'solar:letter-unread-bold': { + body: '', + }, + 'solar:play-broken': { + body: '', + }, + 'solar:multiple-forward-left-broken': { + body: '', + }, + 'solar:dislike-outline': { + body: '', + }, + 'solar:clock-circle-outline': { + body: '', + }, + 'solar:chart-square-outline': { + body: '', + }, + 'solar:hanger-2-outline': { + body: '', + }, + 'solar:home-2-outline': { + body: '', + }, + 'solar:letter-outline': { + body: '', + }, + 'solar:figma-outline': { + body: '', + }, + 'solar:like-outline': { + body: '', + }, + 'solar:atom-outline': { + body: '', + }, + 'solar:gallery-circle-outline': { + body: '', + }, + 'solar:airbuds-case-open-outline': { + body: '', + }, + 'solar:confetti-minimalistic-outline': { + body: '', + }, + 'solar:quit-full-screen-square-outline': { + body: '', + }, + 'solar:lock-password-outline': { + body: '', + }, + 'solar:full-screen-square-outline': { + body: '', + }, + 'solar:add-folder-outline': { + body: '', + }, + 'solar:heart-outline': { + body: '', + }, + 'solar:bill-list-bold-duotone': { + body: '', + }, + 'solar:pallete-2-bold-duotone': { + body: '', + }, + 'solar:bell-bing-bold-duotone': { + body: '', + }, + 'solar:menu-dots-bold-duotone': { + body: '', + }, + 'solar:home-angle-bold-duotone': { + body: '', + }, + 'solar:settings-bold-duotone': { + body: '', + }, + 'solar:notes-bold-duotone': { + body: '', + }, + 'solar:inbox-in-bold-duotone': { + body: '', + }, + 'solar:file-check-bold-duotone': { + body: '', + }, + 'solar:file-corrupted-bold-duotone': { + body: '', + }, + 'solar:file-bold-duotone': { + body: '', + }, + 'solar:cloudy-moon-bold-duotone': { + body: '', + }, + 'solar:align-right-bold-duotone': { + body: '', + }, + 'solar:sort-by-time-bold-duotone': { + body: '', + }, + 'solar:shield-keyhole-bold-duotone': { + body: '', + }, + 'solar:tag-horizontal-bold-duotone': { + body: '', + }, + 'solar:transfer-horizontal-bold-duotone': { + body: '', + }, + 'solar:double-alt-arrow-up-bold-duotone': { + body: '', + }, + 'solar:double-alt-arrow-down-bold-duotone': { + body: '', + }, + 'solar:double-alt-arrow-right-bold-duotone': { + body: '', + }, + 'solar:notebook-bold-duotone': { + body: '', + }, + 'solar:palette-bold-duotone': { + body: '', + }, + 'solar:atom-bold-duotone': { + body: '', + }, + 'solar:users-group-rounded-bold-duotone': { + body: '', + }, + /** + * @set Eva icons + */ + 'eva:attach-2-fill': { + body: '', + }, + 'eva:link-2-fill': { + body: '', + }, + 'eva:star-fill': { + body: '', + }, + 'eva:star-outline': { + body: '', + }, + 'eva:expand-fill': { + body: '', + }, + 'eva:collapse-fill': { + body: '', + }, + 'eva:more-vertical-fill': { + body: '', + }, + 'eva:more-horizontal-fill': { + body: '', + }, + 'eva:award-fill': { + body: '', + }, + 'eva:search-fill': { + body: '', + }, + 'eva:info-outline': { + body: '', + }, + 'eva:activity-fill': { + body: '', + }, + 'eva:done-all-fill': { + body: '', + }, + 'eva:checkmark-fill': { + body: '', + }, + 'eva:smiling-face-fill': { + body: '', + }, + 'eva:external-link-fill': { + body: '', + }, + 'eva:trending-down-fill': { + body: '', + }, + 'eva:trending-up-fill': { + body: '', + }, + 'eva:chevron-down-fill': { + body: '', + }, + 'eva:arrowhead-left-fill': { + body: '', + }, + 'eva:arrowhead-right-fill': { + body: '', + }, + 'eva:arrow-upward-fill': { + body: '', + }, + 'eva:arrow-downward-fill': { + body: '', + }, + 'eva:arrow-forward-fill': { + body: '', + }, + 'eva:arrow-ios-back-fill': { + body: '', + }, + 'eva:arrow-ios-forward-fill': { + body: '', + }, + 'eva:arrow-ios-downward-fill': { + body: '', + }, + 'eva:arrow-ios-upward-fill': { + body: '', + }, + 'eva:diagonal-arrow-right-up-fill': { + body: '', + }, + 'eva:diagonal-arrow-left-down-fill': { + body: '', + }, + 'eva:radio-button-off-fill': { + body: '', + }, + 'eva:checkmark-circle-2-outline': { + body: '', + }, + 'eva:minus-circle-fill': { + body: '', + }, + 'eva:cloud-upload-fill': { + body: ' ', + }, + 'eva:cloud-download-fill': { + body: ' ', + }, + /** + * @set Ic icons + */ + 'ic:round-sentiment-neutral': { + body: '', + }, + 'ic:round-sentiment-satisfied': { + body: '', + }, + 'ic:round-sentiment-dissatisfied': { + body: '', + }, + 'ic:round-sentiment-very-satisfied': { + body: '', + }, + 'ic:round-sentiment-very-dissatisfied': { + body: '', + }, + 'ic:round-format-align-left': { + body: '', + }, + 'ic:round-format-align-center': { + body: '', + }, + 'ic:round-format-align-right': { + body: '', + }, + 'ic:round-format-align-justify': { + body: '', + }, + 'ic:round-view-list': { + body: '', + }, + 'ic:round-view-quilt': { + body: '', + }, + 'ic:round-view-module': { + body: '', + }, + 'ic:baseline-wifi': { + body: '', + }, + 'ic:round-vpn-key': { + body: '', + }, + 'ic:round-filter-list': { + body: '', + }, + 'ic:round-format-bold': { + body: '', + }, + 'ic:round-beach-access': { + body: '', + }, + 'ic:baseline-bluetooth': { + body: '', + }, + 'ic:round-format-italic': { + body: '', + }, + 'ic:round-format-underlined': { + body: '', + }, + 'ic:baseline-arrow-drop-down': { + body: '', + }, + 'ic:baseline-format-color-fill': { + body: '', + }, + 'ic:round-power-settings-new': { + body: '', + }, + 'ic:round-label-important': { + body: '', + }, + /** + * @set Mingcute icons + */ + 'mingcute:calendar-day-line': { + body: '', + }, + 'mingcute:calendar-week-line': { + body: '', + }, + 'mingcute:calendar-month-line': { + body: '', + }, + 'mingcute:add-line': { + body: '', + }, + 'mingcute:minimize-line': { + body: '', + }, + 'mingcute:close-line': { + body: '', + }, + 'mingcute:apple-fill': { + body: '', + }, + 'mingcute:windows-fill': { + body: '', + }, + 'mingcute:location-fill': { + body: '', + }, + 'mingcute:dot-grid-fill': { + body: '', + }, + 'mingcute:quote-left-fill': { + body: '', + }, + 'mingcute:android-2-fill': { + body: '', + }, + /** + * @set Carbon icons + */ + 'carbon:chevron-sort': { + body: '', + }, + 'carbon:zoom-in': { + body: '', + }, + 'carbon:zoom-out': { + body: '', + }, + 'carbon:play': { + body: '', + }, + 'carbon:pause': { + body: '', + }, + 'carbon:close': { + body: '', + }, + 'carbon:chevron-left': { + body: '', + }, + 'carbon:chevron-right': { + body: '', + }, + 'carbon:menu': { + body: '', + }, + 'carbon:bicycle': { + body: '', + }, + 'carbon:delivery': { + body: '', + }, + 'carbon:rocket': { + body: '', + }, + 'carbon:skill-level-basic': { + body: '', + }, + 'carbon:fit-to-screen': { + body: '', + }, + 'carbon:center-to-fit': { + body: '', + }, + /** + * Socials icons + */ + 'socials:linkedin': { + body: '', + }, + 'socials:facebook': { + body: '', + }, + 'socials:github': { + body: '', + }, + 'socials:twitter': { + body: '', + }, + 'socials:google': { + body: ' ', + }, + 'socials:instagram': { + body: '', + }, + /** + * Payments icons + */ + 'payments:mastercard': { + width: 36, + height: 24, + body: ' ', + }, + 'payments:visa': { + width: 36, + height: 24, + body: ' ', + }, + 'payments:paypal': { + body: ' ', + }, + /** + * Custom icons + */ + 'custom:menu-duotone': { + body: ' ', + }, + 'custom:invoice-duotone': { + body: ' ', + }, + 'custom:profile-duotone': { + body: ' ', + }, + 'custom:location-fill': { + body: '', + }, + 'custom:sidebar-unfold-fill': { + body: ' ', + }, + 'custom:sidebar-fold-fill': { + body: ' ', + }, + 'custom:flash-outline': { + body: '', + }, + 'custom:fast-food-fill': { + body: ' ', + }, + 'custom:drag-dots-fill': { + body: '', + }, + 'custom:send-fill': { + body: '', + }, + 'custom:calendar-agenda-outline': { + body: '', + }, +}; diff --git a/apps/frontend/src/components/iconify/iconify.tsx b/apps/frontend/src/components/iconify/iconify.tsx new file mode 100644 index 0000000000..5c752095eb --- /dev/null +++ b/apps/frontend/src/components/iconify/iconify.tsx @@ -0,0 +1,59 @@ +'use client'; + +import type { IconProps } from '@iconify/react'; +import type { IconifyName } from './register-icons'; + +import { useId } from 'react'; +import { Icon } from '@iconify/react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { iconifyClasses } from './classes'; +import { allIconNames, registerIcons } from './register-icons'; + +// ---------------------------------------------------------------------- + +export type IconifyProps = React.ComponentProps & + Omit & { + icon: IconifyName; + }; + +export function Iconify({ className, icon, width = 20, height, sx, ...other }: IconifyProps) { + const uniqueId = useId(); + + if (!allIconNames.includes(icon)) { + console.warn( + [ + `Icon "${icon}" is currently loaded online, which may cause flickering effects.`, + `To ensure a smoother experience, please register your icon collection for offline use.`, + `More information is available at: https://docs.minimals.cc/icons/`, + ].join('\n') + ); + } + + registerIcons(); + + return ( + + ); +} + +// ---------------------------------------------------------------------- + +const IconRoot = styled(Icon)``; diff --git a/apps/frontend/src/components/iconify/index.ts b/apps/frontend/src/components/iconify/index.ts new file mode 100644 index 0000000000..72937f9d07 --- /dev/null +++ b/apps/frontend/src/components/iconify/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './iconify'; + +export * from './register-icons'; diff --git a/apps/frontend/src/components/iconify/register-icons.ts b/apps/frontend/src/components/iconify/register-icons.ts new file mode 100644 index 0000000000..f00575be69 --- /dev/null +++ b/apps/frontend/src/components/iconify/register-icons.ts @@ -0,0 +1,51 @@ +import type { IconifyJSON } from '@iconify/react'; + +import { addCollection } from '@iconify/react'; + +import allIcons from './icon-sets'; + +// ---------------------------------------------------------------------- + +export const iconSets = Object.entries(allIcons).reduce((acc, [key, value]) => { + const [prefix, iconName] = key.split(':'); + const existingPrefix = acc.find((item) => item.prefix === prefix); + + if (existingPrefix) { + existingPrefix.icons[iconName] = value; + } else { + acc.push({ + prefix, + icons: { + [iconName]: value, + }, + }); + } + + return acc; +}, [] as IconifyJSON[]); + +export const allIconNames = Object.keys(allIcons) as IconifyName[]; + +export type IconifyName = keyof typeof allIcons; + +// ---------------------------------------------------------------------- + +let areIconsRegistered = false; + +export function registerIcons() { + if (areIconsRegistered) { + return; + } + + iconSets.forEach((iconSet) => { + const iconSetConfig = { + ...iconSet, + width: (iconSet.prefix === 'carbon' && 32) || 24, + height: (iconSet.prefix === 'carbon' && 32) || 24, + }; + + addCollection(iconSetConfig); + }); + + areIconsRegistered = true; +} diff --git a/apps/frontend/src/components/label/classes.ts b/apps/frontend/src/components/label/classes.ts new file mode 100644 index 0000000000..00420532b5 --- /dev/null +++ b/apps/frontend/src/components/label/classes.ts @@ -0,0 +1,8 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const labelClasses = { + root: createClasses('label__root'), + icon: createClasses('label__icon'), +}; diff --git a/apps/frontend/src/components/label/index.ts b/apps/frontend/src/components/label/index.ts new file mode 100644 index 0000000000..71f3c7e644 --- /dev/null +++ b/apps/frontend/src/components/label/index.ts @@ -0,0 +1,7 @@ +export * from './label'; + +export * from './styles'; + +export * from './classes'; + +export type * from './types'; diff --git a/apps/frontend/src/components/label/label.tsx b/apps/frontend/src/components/label/label.tsx new file mode 100644 index 0000000000..11b4682983 --- /dev/null +++ b/apps/frontend/src/components/label/label.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { LabelProps } from './types'; + +import { upperFirst } from 'es-toolkit'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { labelClasses } from './classes'; +import { LabelRoot, LabelIcon } from './styles'; + +// ---------------------------------------------------------------------- + +export function Label({ + sx, + endIcon, + children, + startIcon, + className, + disabled, + variant = 'soft', + color = 'default', + ...other +}: LabelProps) { + return ( + + {startIcon && {startIcon}} + + {typeof children === 'string' ? upperFirst(children) : children} + + {endIcon && {endIcon}} + + ); +} diff --git a/apps/frontend/src/components/label/styles.tsx b/apps/frontend/src/components/label/styles.tsx new file mode 100644 index 0000000000..d2928e4b17 --- /dev/null +++ b/apps/frontend/src/components/label/styles.tsx @@ -0,0 +1,139 @@ +'use client'; + +import type { LabelVariant } from './types'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { colorKeys } from 'src/theme/core'; + +// ---------------------------------------------------------------------- + +const baseColors = ['default'] as const; +const allColors = [...baseColors, ...colorKeys.palette, ...colorKeys.common] as const; + +export const LabelRoot = styled('span', { + shouldForwardProp: (prop: string) => !['color', 'variant', 'disabled', 'sx'].includes(prop), +})<{ variant?: LabelVariant; disabled?: boolean }>(({ theme }) => ({ + height: 24, + minWidth: 24, + flexShrink: 0, + lineHeight: 18 / 12, + cursor: 'default', + alignItems: 'center', + whiteSpace: 'nowrap', + display: 'inline-flex', + gap: theme.spacing(0.75), + justifyContent: 'center', + padding: theme.spacing(0, 0.75), + fontSize: theme.typography.pxToRem(12), + fontWeight: theme.typography.fontWeightBold, + borderRadius: Number(theme.shape.borderRadius) * 0.75, + variants: [ + /** + * @variant filled + */ + { + props: { variant: 'filled', color: 'default' }, + style: { + ...theme.mixins.filledStyles(theme, 'inherit'), + }, + }, + ...colorKeys.common.map((colorKey) => ({ + props: { variant: 'filled', color: colorKey }, + style: { + ...theme.mixins.filledStyles(theme, colorKey), + }, + })), + ...colorKeys.palette.map((colorKey) => ({ + props: { variant: 'filled', color: colorKey }, + style: { + ...theme.mixins.filledStyles(theme, colorKey), + }, + })), + /** + * @variant outlined + */ + { + props: { variant: 'outlined' }, + style: { + border: '2px solid currentColor', + }, + }, + ...colorKeys.common.map((colorKey) => ({ + props: { variant: 'outlined', color: colorKey }, + style: { + color: theme.vars.palette.common[colorKey], + }, + })), + ...colorKeys.palette.map((colorKey) => ({ + props: { variant: 'outlined', color: colorKey }, + style: { + color: theme.vars.palette[colorKey].main, + }, + })), + /** + * @variant soft + */ + ...allColors.map((colorKey) => ({ + props: { variant: 'soft', color: colorKey }, + style: () => { + const currentColor = colorKey === 'default' ? 'inherit' : colorKey; + + return { + ...theme.mixins.softStyles(theme, currentColor), + }; + }, + })), + /** + * @variant inverted + */ + { + props: { variant: 'inverted', color: 'default' }, + style: { + color: theme.vars.palette.grey[800], + backgroundColor: theme.vars.palette.grey[300], + }, + }, + ...colorKeys.common.map((colorKey) => ({ + props: { variant: 'inverted', color: colorKey }, + style: { + color: theme.vars.palette.common[colorKey], + backgroundColor: varAlpha('currentColor', theme.vars.opacity.soft.commonHoverBg), + }, + })), + ...colorKeys.palette.map((colorKey) => ({ + props: { variant: 'inverted', color: colorKey }, + style: { + color: theme.vars.palette[colorKey].darker, + backgroundColor: theme.vars.palette[colorKey].lighter, + ...theme.applyStyles('dark', { + color: theme.vars.palette[colorKey].lighter, + backgroundColor: theme.vars.palette[colorKey].darker, + }), + }, + })), + /** + * @disabled + */ + { + props: { disabled: true }, + style: { + opacity: 0.48, + pointerEvents: 'none', + }, + }, + ], +})); + +export const LabelIcon = styled('span')({ + width: 16, + height: 16, + flexShrink: 0, + '& svg, & img': { + width: '100%', + height: '100%', + objectFit: 'cover', + }, +}); diff --git a/apps/frontend/src/components/label/types.ts b/apps/frontend/src/components/label/types.ts new file mode 100644 index 0000000000..112c50628a --- /dev/null +++ b/apps/frontend/src/components/label/types.ts @@ -0,0 +1,16 @@ +import type { LabelRoot } from './styles'; +import type { PaletteColorKey, CommonColorsKeys } from 'src/theme/core'; + +// ---------------------------------------------------------------------- + +export type LabelColor = PaletteColorKey | CommonColorsKeys | 'default'; + +export type LabelVariant = 'filled' | 'outlined' | 'soft' | 'inverted'; + +export interface LabelProps extends React.ComponentProps { + disabled?: boolean; + color?: LabelColor; + variant?: LabelVariant; + endIcon?: React.ReactNode; + startIcon?: React.ReactNode; +} diff --git a/apps/frontend/src/components/scrollbar/classes.ts b/apps/frontend/src/components/scrollbar/classes.ts new file mode 100644 index 0000000000..8cdeaccf6b --- /dev/null +++ b/apps/frontend/src/components/scrollbar/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const scrollbarClasses = { + root: createClasses('scrollbar__root'), +}; diff --git a/apps/frontend/src/components/scrollbar/index.ts b/apps/frontend/src/components/scrollbar/index.ts new file mode 100644 index 0000000000..a483df68bd --- /dev/null +++ b/apps/frontend/src/components/scrollbar/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './scrollbar'; + +export type * from './types'; diff --git a/apps/frontend/src/components/scrollbar/scrollbar.tsx b/apps/frontend/src/components/scrollbar/scrollbar.tsx new file mode 100644 index 0000000000..8d2c51ef3b --- /dev/null +++ b/apps/frontend/src/components/scrollbar/scrollbar.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { ScrollbarProps } from './types'; + +import SimpleBar from 'simplebar-react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { scrollbarClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export function Scrollbar({ + sx, + ref, + children, + className, + slotProps, + fillContent = true, + ...other +}: ScrollbarProps) { + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +const ScrollbarRoot = styled(SimpleBar, { + shouldForwardProp: (prop: string) => !['fillContent', 'sx'].includes(prop), +})>(({ fillContent }) => ({ + minWidth: 0, + minHeight: 0, + flexGrow: 1, + display: 'flex', + flexDirection: 'column', + ...(fillContent && { + '& .simplebar-content': { + display: 'flex', + flex: '1 1 auto', + minHeight: '100%', + flexDirection: 'column', + }, + }), +})); diff --git a/apps/frontend/src/components/scrollbar/styles.css b/apps/frontend/src/components/scrollbar/styles.css new file mode 100644 index 0000000000..2fbf4d99df --- /dev/null +++ b/apps/frontend/src/components/scrollbar/styles.css @@ -0,0 +1,8 @@ +@import 'simplebar-react/dist/simplebar.min.css'; + +.simplebar-scrollbar:before { + background-color: var(--palette-text-disabled); +} +.simplebar-scrollbar.simplebar-visible:before { + opacity: 0.48; +} diff --git a/apps/frontend/src/components/scrollbar/types.ts b/apps/frontend/src/components/scrollbar/types.ts new file mode 100644 index 0000000000..c53cbb9f06 --- /dev/null +++ b/apps/frontend/src/components/scrollbar/types.ts @@ -0,0 +1,15 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { Props as SimplebarProps } from 'simplebar-react'; + +// ---------------------------------------------------------------------- + +export type ScrollbarProps = SimplebarProps & + React.ComponentProps<'div'> & { + sx?: SxProps; + fillContent?: boolean; + slotProps?: { + wrapperSx?: SxProps; + contentSx?: SxProps; + contentWrapperSx?: SxProps; + }; + }; diff --git a/apps/frontend/src/global-config.ts b/apps/frontend/src/global-config.ts new file mode 100644 index 0000000000..3ba450b760 --- /dev/null +++ b/apps/frontend/src/global-config.ts @@ -0,0 +1,4 @@ +export const CONFIG = { + apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3333', + assetsDir: import.meta.env.VITE_ASSETS_DIR ?? '', +}; diff --git a/apps/frontend/src/global.css b/apps/frontend/src/global.css new file mode 100644 index 0000000000..e2df9c058e --- /dev/null +++ b/apps/frontend/src/global.css @@ -0,0 +1,48 @@ +/** ************************************** +* Fonts +* Só as duas famílias que o tema usa (primary/secondary); as demais opções do +* kit original ficaram de fora para não carregar fonte que ninguém renderiza. +*************************************** */ +@import '@fontsource-variable/public-sans'; +@import '@fontsource-variable/dm-sans'; + +/** ************************************** +* Plugins +*************************************** */ +@import './components/scrollbar/styles.css'; + +/** ************************************** +* Baseline +*************************************** */ +html { + height: 100%; + -webkit-overflow-scrolling: touch; +} +body, +#root { + display: flex; + flex: 1 1 auto; + min-height: 100%; + flex-direction: column; +} +img { + max-width: 100%; + vertical-align: middle; +} +ul { + margin: 0; + padding: 0; + list-style-type: none; +} +input[type='number'] { + -moz-appearance: textfield; + appearance: none; +} +input[type='number']::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; +} +input[type='number']::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} diff --git a/apps/frontend/src/lib/orpc-client.test.ts b/apps/frontend/src/lib/orpc-client.test.ts new file mode 100644 index 0000000000..f1de74a1bd --- /dev/null +++ b/apps/frontend/src/lib/orpc-client.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { getOrpcErrorMessage } from './orpc-client'; + +describe('getOrpcErrorMessage', () => { + it('traduz falha de rede em instrução acionável, não no "Failed to fetch" do browser', () => { + const message = getOrpcErrorMessage(new TypeError('Failed to fetch')); + + expect(message).toContain('Não foi possível conectar à API'); + expect(message).toContain('bun run dev'); + expect(message).not.toContain('Failed to fetch'); + }); + + it('preserva a mensagem de erro vinda da API', () => { + expect(getOrpcErrorMessage(new Error('Máquina MCH-999 não encontrada'))).toBe( + 'Máquina MCH-999 não encontrada' + ); + }); + + it('não confunde TypeError comum com falha de rede', () => { + expect(getOrpcErrorMessage(new TypeError('x is not a function'))).toBe('x is not a function'); + }); + + it('aceita erro em string e objeto com message', () => { + expect(getOrpcErrorMessage('erro cru')).toBe('erro cru'); + expect(getOrpcErrorMessage({ message: 'do objeto' })).toBe('do objeto'); + }); + + it('tem mensagem padrão para erro sem formato conhecido', () => { + expect(getOrpcErrorMessage(null)).toBe('Falha ao buscar os dados do sensor.'); + }); +}); diff --git a/apps/frontend/src/lib/orpc-client.ts b/apps/frontend/src/lib/orpc-client.ts new file mode 100644 index 0000000000..a4c904a4d7 --- /dev/null +++ b/apps/frontend/src/lib/orpc-client.ts @@ -0,0 +1,32 @@ +import { createORPCClient, onError } from '@orpc/client'; +import type { ContractRouterClient } from '@orpc/contract'; +import type { JsonifiedClient } from '@orpc/openapi-client'; +import { OpenAPILink } from '@orpc/openapi-client/fetch'; +import { contracts } from '@repo/contracts'; + +import { CONFIG } from 'src/global-config'; + +const link = new OpenAPILink(contracts, { + url: () => `${CONFIG.apiUrl}/api`, + headers: () => ({ 'Content-Type': 'application/json' }), + interceptors: [onError((error) => console.error('[orpc-client]', error))], +}); + +export const orpcClient: JsonifiedClient> = createORPCClient(link); + +const isNetworkError = (error: unknown): boolean => + error instanceof TypeError && /fetch|network|load failed/i.test(error.message); + +export const getOrpcErrorMessage = (error: unknown): string => { + if (isNetworkError(error)) { + return `Não foi possível conectar à API em ${CONFIG.apiUrl}. Verifique se o backend está no ar (bun run dev).`; + } + + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message; + } + + return 'Falha ao buscar os dados do sensor.'; +}; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000000..69ddf99151 --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { RouterProvider } from 'react-router'; + +import { router } from './routes'; +import { store } from './store'; +import { ThemeProvider } from './theme'; + +import '@fontsource-variable/dm-sans'; +import '@fontsource-variable/public-sans'; +import './global.css'; + +createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx new file mode 100644 index 0000000000..2a442a8c89 --- /dev/null +++ b/apps/frontend/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createBrowserRouter, Navigate } from 'react-router'; + +import { DataView } from 'src/sections/data/data-view'; + +export const router = createBrowserRouter([ + { index: true, element: }, + { path: 'data', element: }, + { path: '*', element: }, +]); diff --git a/apps/frontend/src/sections/data/chart-options.test.ts b/apps/frontend/src/sections/data/chart-options.test.ts new file mode 100644 index 0000000000..6c6ae55189 --- /dev/null +++ b/apps/frontend/src/sections/data/chart-options.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { makeSeries } from 'src/test/fixtures'; +import type { MetricGroup } from 'src/store/measurements/selectors'; + +import { buildChartOptions, seriesColor, seriesLabel, toChartData, type ChartPalette } from './chart-options'; + +const palette: ChartPalette = { + text: '#fff', + subtext: '#999', + divider: '#333', + tooltipBg: '#111', + buttonText: '#fff', + buttonBg: 'rgba(145, 158, 171, 0.16)', + buttonHoverBg: 'rgba(145, 158, 171, 0.32)', +}; + +const accelerationGroup: MetricGroup = { + metric: 'acceleration', + label: 'Aceleração RMS', + unit: 'g', + series: [ + makeSeries('accelerationRms/x', 'acceleration', 'x'), + makeSeries('accelerationRms/y', 'acceleration', 'y'), + ], +}; + +const temperatureGroup: MetricGroup = { + metric: 'temperature', + label: 'Temperatura', + unit: '°C', + series: [makeSeries('temperature', 'temperature', null)], +}; + +describe('toChartData', () => { + it('converte cada ponto no par [timestamp, valor]', () => { + const [first] = toChartData(makeSeries('velocityRms/x', 'velocity', 'x', [7])); + + expect(first).toEqual([Date.UTC(2023, 10, 7), 7]); + }); + + it('descarta pontos com datetime inválido em vez de plotar NaN', () => { + const series = makeSeries('velocityRms/x', 'velocity', 'x', [1, 2]); + series.points[0].datetime = 'quebrado'; + + expect(toChartData(series)).toHaveLength(1); + }); +}); + +describe('seriesLabel / seriesColor', () => { + it('nomeia pelo eixo quando existe e usa a mesma cor por eixo', () => { + const x = makeSeries('velocityRms/x', 'velocity', 'x'); + const accelX = makeSeries('accelerationRms/x', 'acceleration', 'x'); + + expect(seriesLabel(x)).toBe('Eixo X'); + expect(seriesColor(accelX)).toBe(seriesColor(x)); + }); + + it('trata série escalar sem eixo', () => { + const temperature = makeSeries('temperature', 'temperature', null); + + expect(seriesLabel(temperature)).toBe('Leitura'); + expect(seriesColor(temperature)).toBeDefined(); + }); +}); + +describe('buildChartOptions', () => { + it('usa eixo de tempo com crosshair habilitado', () => { + const options = buildChartOptions(accelerationGroup, palette); + + expect(options.xAxis).toMatchObject({ type: 'datetime' }); + expect((options.xAxis as Highcharts.XAxisOptions).crosshair).toBeTruthy(); + }); + + it('fixa a mesma margem esquerda em todos os gráficos, para o crosshair alinhar', () => { + const acceleration = buildChartOptions(accelerationGroup, palette); + const temperature = buildChartOptions(temperatureGroup, palette); + + expect(acceleration.chart?.marginLeft).toBe(temperature.chart?.marginLeft); + }); + + it('mostra legenda só quando há mais de uma série', () => { + expect(buildChartOptions(accelerationGroup, palette).legend?.enabled).toBe(true); + expect(buildChartOptions(temperatureGroup, palette).legend?.enabled).toBe(false); + }); + + it('usa tooltip compartilhado com a unidade da grandeza', () => { + const options = buildChartOptions(temperatureGroup, palette); + + expect(options.tooltip).toMatchObject({ shared: true, valueSuffix: ' °C' }); + }); + + it('dá mais casas decimais à aceleração, que opera em valores muito baixos', () => { + expect(buildChartOptions(accelerationGroup, palette).tooltip?.valueDecimals).toBe(4); + expect(buildChartOptions(temperatureGroup, palette).tooltip?.valueDecimals).toBe(2); + }); + + it('cria uma série de gráfico por série de dados', () => { + const options = buildChartOptions(accelerationGroup, palette); + + expect(options.series).toHaveLength(2); + expect(options.series?.[0]).toMatchObject({ id: 'accelerationRms/x', name: 'Eixo X' }); + }); +}); diff --git a/apps/frontend/src/sections/data/chart-options.ts b/apps/frontend/src/sections/data/chart-options.ts new file mode 100644 index 0000000000..bff63566b9 --- /dev/null +++ b/apps/frontend/src/sections/data/chart-options.ts @@ -0,0 +1,109 @@ +import type { Series } from '@repo/contracts'; +import type Highcharts from 'highcharts'; + +import type { MetricGroup } from 'src/store/measurements/selectors'; + +export const AXIS_COLORS: Record = { + x: '#00A76F', + y: '#FFAB00', + z: '#8E33FF', + scalar: '#00B8D9', +}; + +const PLOT_MARGIN_LEFT = 64; + +export const seriesLabel = (series: Series): string => + series.axis ? `Eixo ${series.axis.toUpperCase()}` : 'Leitura'; + +export const seriesColor = (series: Series): string => AXIS_COLORS[series.axis ?? 'scalar']; + +export type ChartPalette = { + text: string; + subtext: string; + divider: string; + tooltipBg: string; + buttonText: string; + buttonBg: string; + buttonHoverBg: string; +}; + +export const toChartData = (series: Series): [number, number][] => + series.points + .map((point): [number, number] => [new Date(point.datetime).getTime(), point.max]) + .filter(([timestamp]) => !Number.isNaN(timestamp)); + +export const buildChartOptions = (group: MetricGroup, palette: ChartPalette): Highcharts.Options => { + const decimals = group.metric === 'acceleration' ? 4 : 2; + + return { + chart: { + height: 280, + backgroundColor: 'transparent', + zooming: { type: 'x' }, + marginLeft: PLOT_MARGIN_LEFT, + marginRight: 24, + style: { fontFamily: 'inherit' }, + resetZoomButton: { + position: { align: 'right', x: -4, y: 4 }, + theme: { + fill: palette.buttonBg, + stroke: 'none', + r: 8, + padding: 6, + style: { color: palette.buttonText, fontWeight: '700', fontSize: '12px' }, + states: { + hover: { + fill: palette.buttonHoverBg, + style: { color: palette.buttonText }, + }, + }, + }, + }, + }, + title: { text: undefined }, + credits: { enabled: false }, + accessibility: { description: `Série temporal de ${group.label} em ${group.unit}` }, + xAxis: { + type: 'datetime', + crosshair: { width: 1, dashStyle: 'Dash', color: palette.subtext }, + lineColor: palette.divider, + tickColor: palette.divider, + labels: { style: { color: palette.subtext } }, + }, + yAxis: { + title: { text: group.unit, style: { color: palette.subtext } }, + gridLineColor: palette.divider, + labels: { style: { color: palette.subtext } }, + startOnTick: false, + endOnTick: false, + }, + tooltip: { + shared: true, + backgroundColor: palette.tooltipBg, + borderWidth: 0, + shadow: true, + style: { color: palette.text }, + valueSuffix: ` ${group.unit}`, + valueDecimals: decimals, + xDateFormat: '%d/%m/%Y %H:%M', + }, + legend: { + enabled: group.series.length > 1, + itemStyle: { color: palette.subtext }, + itemHoverStyle: { color: palette.text }, + }, + plotOptions: { + series: { + marker: { enabled: false, symbol: 'circle', states: { hover: { radius: 4 } } }, + states: { inactive: { opacity: 1 } }, + }, + }, + series: group.series.map((series) => ({ + type: 'line', + id: series.id, + name: seriesLabel(series), + color: seriesColor(series), + data: toChartData(series), + })), + }; +}; diff --git a/apps/frontend/src/sections/data/chart-sync.tsx b/apps/frontend/src/sections/data/chart-sync.tsx new file mode 100644 index 0000000000..06ca1f70a3 --- /dev/null +++ b/apps/frontend/src/sections/data/chart-sync.tsx @@ -0,0 +1,70 @@ +import type Highcharts from 'highcharts'; +import { createContext, use, useCallback, useMemo, useRef, type ReactNode } from 'react'; + +import Box from '@mui/material/Box'; + +type ChartSyncContextValue = { + register: (chart: Highcharts.Chart) => () => void; +}; + +const ChartSyncContext = createContext(null); + +export const useChartSync = () => use(ChartSyncContext); + +export function ChartSyncGroup({ children }: { children: ReactNode }) { + const chartsRef = useRef>(new Set()); + + const register = useCallback((chart: Highcharts.Chart) => { + chartsRef.current.add(chart); + + return () => { + chartsRef.current.delete(chart); + }; + }, []); + + const handleMove = useCallback((event: React.MouseEvent | React.TouchEvent) => { + chartsRef.current.forEach((chart) => { + if (!chart.pointer || !chart.series?.length) return; + + const normalized = chart.pointer.normalize(event.nativeEvent); + const hovered = chart.series[0].searchPoint(normalized, true); + + if (!hovered) return; + + const points = chart.series + .map((series) => series.points?.[hovered.index]) + .filter((point): point is Highcharts.Point => Boolean(point)); + + points.forEach((point) => point.setState('hover')); + chart.tooltip?.refresh(points); + chart.xAxis[0]?.drawCrosshair(normalized, hovered); + }); + }, []); + + const handleLeave = useCallback(() => { + chartsRef.current.forEach((chart) => { + if (!chart.pointer || !chart.series?.length) return; + + chart.series.forEach((series) => series.points?.forEach((point) => point.setState(''))); + chart.tooltip?.hide(); + chart.xAxis[0]?.hideCrosshair(); + }); + }, []); + + const value = useMemo(() => ({ register }), [register]); + + return ( + + + {children} + + + ); +} diff --git a/apps/frontend/src/sections/data/data-toolbar.tsx b/apps/frontend/src/sections/data/data-toolbar.tsx new file mode 100644 index 0000000000..e39add416d --- /dev/null +++ b/apps/frontend/src/sections/data/data-toolbar.tsx @@ -0,0 +1,56 @@ +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +import { useAppDispatch, useAppSelector } from 'src/store/hooks'; +import { machineSelected } from 'src/store/machines/slice'; +import { PERIOD_LABELS, PERIODS, type Period } from 'src/store/measurements/period'; +import { periodChanged } from 'src/store/measurements/slice'; + +export function DataToolbar() { + const dispatch = useAppDispatch(); + + const machines = useAppSelector((state) => state.machines.items); + const selectedId = useAppSelector((state) => state.machines.selectedId); + const period = useAppSelector((state) => state.measurements.period); + + return ( + + dispatch(machineSelected(event.target.value))} + sx={{ minWidth: 260 }} + slotProps={{ htmlInput: { 'aria-label': 'Selecionar máquina' } }} + > + {machines.map((machine) => ( + + {machine.name} + + ))} + + + value && dispatch(periodChanged(value))} + > + {PERIODS.map((item) => ( + + {PERIOD_LABELS[item]} + + ))} + + + ); +} diff --git a/apps/frontend/src/sections/data/data-view.test.tsx b/apps/frontend/src/sections/data/data-view.test.tsx new file mode 100644 index 0000000000..7058fb7196 --- /dev/null +++ b/apps/frontend/src/sections/data/data-view.test.tsx @@ -0,0 +1,199 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { MemoryRouter, useLocation } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ThemeProvider } from 'src/theme'; + +import { machineFixture, seriesFixture } from 'src/test/fixtures'; + +const listMachines = vi.fn(); +const listMeasurements = vi.fn(); + +vi.mock('src/lib/orpc-client', async () => { + const actual = await vi.importActual('src/lib/orpc-client'); + + return { + ...actual, + orpcClient: { + machines: { list: (...args: unknown[]) => listMachines(...args) }, + measurements: { list: (...args: unknown[]) => listMeasurements(...args) }, + }, + }; +}); + +vi.mock('./metric-chart', () => ({ + MetricChart: ({ group }: { group: { label: string; series: unknown[] } }) => ( +
{`${group.label}: ${group.series.length}`}
+ ), +})); + +const { DataView } = await import('./data-view'); +const { createStore } = await import('src/store'); + +function LocationProbe() { + return {useLocation().search}; +} + +const urlSearch = () => screen.getByTestId('url-search').textContent; + +const urlFilters = () => { + const params = new URLSearchParams(urlSearch() ?? ''); + + return { machine: params.get('machine'), period: params.get('period') }; +}; + +const renderView = (initialUrl = '/data') => + render( + + + + + + + + + ); + +describe('DataView', () => { + beforeEach(() => { + vi.clearAllMocks(); + listMachines.mockResolvedValue({ data: [machineFixture, { ...machineFixture, id: 'MCH-002', name: 'Bomba Centrífuga 04' }] }); + listMeasurements.mockResolvedValue({ machineId: 'MCH-001', series: seriesFixture }); + }); + + it('busca os dados ao acessar a rota e exibe um gráfico por grandeza', async () => { + renderView(); + + await waitFor(() => expect(screen.getAllByTestId('metric-chart')).toHaveLength(3)); + + expect(listMachines).toHaveBeenCalledTimes(1); + expect(listMeasurements).toHaveBeenCalledWith(expect.objectContaining({ machineId: 'MCH-001' })); + }); + + it('exibe o cabeçalho com as informações da máquina', async () => { + renderView(); + + expect(await screen.findByRole('heading', { name: 'Ventilador de Exaustão 01' })).toBeInTheDocument(); + expect(screen.getByText('1780 RPM')).toBeInTheDocument(); + expect(screen.getByText('DynaLogger TcAg · HF-8842-2023')).toBeInTheDocument(); + expect(screen.getByText('Em alerta')).toBeInTheDocument(); + }); + + it('rebusca com o recorte de tempo ao trocar o período', async () => { + const user = userEvent.setup(); + renderView(); + + await waitFor(() => expect(listMeasurements).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: '7 dias' })); + + await waitFor(() => expect(listMeasurements).toHaveBeenCalledTimes(2)); + + expect(listMeasurements).toHaveBeenLastCalledWith({ + machineId: 'MCH-001', + from: '2023-12-05T15:02:42.000Z', + to: '2023-12-12T15:02:42.000Z', + }); + }); + + it('rebusca ao trocar de máquina', async () => { + const user = userEvent.setup(); + renderView(); + + await waitFor(() => expect(listMeasurements).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByLabelText('Selecionar máquina')); + await user.click(within(await screen.findByRole('listbox')).getByText('Bomba Centrífuga 04')); + + await waitFor(() => + expect(listMeasurements).toHaveBeenLastCalledWith(expect.objectContaining({ machineId: 'MCH-002' })) + ); + }); + + it('mostra o erro da API com ação de tentar de novo', async () => { + listMeasurements.mockRejectedValue(new Error('API fora do ar')); + const user = userEvent.setup(); + renderView(); + + expect(await screen.findByText('API fora do ar')).toBeInTheDocument(); + expect(screen.queryAllByTestId('metric-chart')).toHaveLength(0); + + listMeasurements.mockResolvedValue({ machineId: 'MCH-001', series: seriesFixture }); + await user.click(screen.getByRole('button', { name: 'Tentar de novo' })); + + await waitFor(() => expect(screen.getAllByTestId('metric-chart')).toHaveLength(3)); + }); + + it('avisa quando o período selecionado não tem leitura', async () => { + listMeasurements.mockResolvedValue({ machineId: 'MCH-001', series: [] }); + renderView(); + + expect(await screen.findByText('Nenhuma leitura no período')).toBeInTheDocument(); + }); +}); + +describe('DataView — filtros na URL', () => { + beforeEach(() => { + vi.clearAllMocks(); + listMachines.mockResolvedValue({ + data: [machineFixture, { ...machineFixture, id: 'MCH-002', name: 'Bomba Centrífuga 04' }], + }); + listMeasurements.mockResolvedValue({ machineId: 'MCH-001', series: seriesFixture }); + }); + + it('aplica máquina e período vindos da URL ao abrir o link', async () => { + renderView('/data?machine=MCH-002&period=7d'); + + await waitFor(() => + expect(listMeasurements).toHaveBeenLastCalledWith({ + machineId: 'MCH-002', + from: '2023-12-05T15:02:42.000Z', + to: '2023-12-12T15:02:42.000Z', + }) + ); + + expect(screen.getByRole('button', { name: '7 dias' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('reflete na URL os filtros escolhidos, para o link ser compartilhável', async () => { + const user = userEvent.setup(); + renderView(); + + await waitFor(() => expect(urlFilters()).toEqual({ machine: 'MCH-001', period: 'all' })); + + await user.click(screen.getByRole('button', { name: '30 dias' })); + + await waitFor(() => expect(urlFilters()).toEqual({ machine: 'MCH-001', period: '30d' })); + }); + + it('reflete na URL a troca de máquina', async () => { + const user = userEvent.setup(); + renderView(); + + await waitFor(() => expect(urlSearch()).toContain('machine=MCH-001')); + + await user.click(screen.getByLabelText('Selecionar máquina')); + await user.click(within(await screen.findByRole('listbox')).getByText('Bomba Centrífuga 04')); + + await waitFor(() => expect(urlSearch()).toContain('machine=MCH-002')); + }); + + it('cai para a primeira máquina quando a URL aponta para uma inexistente', async () => { + renderView('/data?machine=NAO-EXISTE'); + + await waitFor(() => + expect(listMeasurements).toHaveBeenLastCalledWith(expect.objectContaining({ machineId: 'MCH-001' })) + ); + + await waitFor(() => expect(urlSearch()).toContain('machine=MCH-001')); + }); + + it('ignora período inválido na URL e mantém o padrão', async () => { + renderView('/data?period=lixo'); + + await waitFor(() => expect(urlFilters()).toEqual({ machine: 'MCH-001', period: 'all' })); + expect(screen.getByRole('button', { name: 'Tudo' })).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/apps/frontend/src/sections/data/data-view.tsx b/apps/frontend/src/sections/data/data-view.tsx new file mode 100644 index 0000000000..37dadd2123 --- /dev/null +++ b/apps/frontend/src/sections/data/data-view.tsx @@ -0,0 +1,98 @@ +import { useEffect } from 'react'; + +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; + +import { EmptyContent } from 'src/components/empty-content'; +import { useAppDispatch, useAppSelector } from 'src/store/hooks'; +import { fetchMachines } from 'src/store/machines/slice'; +import { selectMetricGroups, selectSelectedMachine } from 'src/store/measurements/selectors'; +import { fetchMeasurements } from 'src/store/measurements/slice'; + +import { ChartSyncGroup } from './chart-sync'; +import { DataToolbar } from './data-toolbar'; +import { MachineHeader } from './machine-header'; +import { MetricChart } from './metric-chart'; +import { useUrlFilters } from './use-url-filters'; + +export function DataView() { + const dispatch = useAppDispatch(); + + useUrlFilters(); + + const machine = useAppSelector(selectSelectedMachine); + const groups = useAppSelector(selectMetricGroups); + const period = useAppSelector((state) => state.measurements.period); + const machines = useAppSelector((state) => state.machines); + const measurements = useAppSelector((state) => state.measurements); + + useEffect(() => { + dispatch(fetchMachines()); + }, [dispatch]); + + useEffect(() => { + if (machine) dispatch(fetchMeasurements({ machineId: machine.id })); + }, [dispatch, machine, period]); + + const error = machines.error ?? measurements.error; + const isLoading = machines.status === 'loading' || measurements.status === 'loading'; + + const retry = () => { + dispatch(fetchMachines()); + if (machine) dispatch(fetchMeasurements({ machineId: machine.id })); + }; + + return ( + + + + + {error && ( + + Tentar de novo + + } + > + {error} + + )} + + {machine && } + + {isLoading && } + + {!isLoading && !error && groups.length === 0 && ( + + )} + + {!isLoading && groups.length > 0 && ( + + {groups.map((group) => ( + + ))} + + )} + + + ); +} + +function ChartsSkeleton() { + return ( + + {[0, 1, 2].map((index) => ( + + ))} + + ); +} diff --git a/apps/frontend/src/sections/data/machine-header.tsx b/apps/frontend/src/sections/data/machine-header.tsx new file mode 100644 index 0000000000..9f0da9c9bf --- /dev/null +++ b/apps/frontend/src/sections/data/machine-header.tsx @@ -0,0 +1,103 @@ +import type { Machine, MachineStatus } from '@repo/contracts'; + +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import type { IconifyName } from 'src/components/iconify'; +import { Iconify } from 'src/components/iconify'; +import { Label } from 'src/components/label'; + +const STATUS_LABELS: Record = + { + operational: { text: 'Operando', color: 'success' }, + alert: { text: 'Em alerta', color: 'warning' }, + critical: { text: 'Crítico', color: 'error' }, + stopped: { text: 'Parada', color: 'default' }, + }; + +const formatDateTime = (value: string) => { + const date = new Date(value); + + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('pt-BR'); +}; + +type Props = { machine: Machine }; + +export function MachineHeader({ machine }: Props) { + const status = STATUS_LABELS[machine.status]; + + const details: { icon: IconifyName; label: string; value: string }[] = [ + { icon: 'solar:settings-bold', label: 'Tipo', value: machine.type }, + { icon: 'solar:case-minimalistic-bold', label: 'Fabricante', value: machine.manufacturer }, + { icon: 'solar:restart-bold', label: 'Rotação', value: `${machine.rpm} RPM` }, + { + icon: 'solar:ssd-round-bold', + label: 'Sensor', + value: `${machine.sensor.model} · ${machine.sensor.serialNumber}`, + }, + { icon: 'mingcute:location-fill', label: 'Posição', value: machine.sensor.position }, + { + icon: 'solar:clock-circle-bold', + label: 'Última leitura', + value: formatDateTime(machine.lastReadingAt), + }, + ]; + + return ( + + + + + + + + + {machine.name} + + + {machine.id} · {machine.sensor.position} + + + + + + + + + + {details.map((detail) => ( + + + + + + {detail.label} + + + {detail.value} + + + + ))} + + + ); +} diff --git a/apps/frontend/src/sections/data/metric-chart.tsx b/apps/frontend/src/sections/data/metric-chart.tsx new file mode 100644 index 0000000000..d421d56540 --- /dev/null +++ b/apps/frontend/src/sections/data/metric-chart.tsx @@ -0,0 +1,65 @@ +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { useEffect, useMemo, useRef } from 'react'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import { useTheme } from '@mui/material/styles'; +import { varAlpha } from 'minimal-shared/utils'; + +import type { MetricGroup } from 'src/store/measurements/selectors'; + +import { buildChartOptions, type ChartPalette } from './chart-options'; +import { useChartSync } from './chart-sync'; +import { SeriesStatsRow } from './series-stats-row'; + +import 'highcharts/modules/accessibility'; + +Highcharts.setOptions({ + time: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }, + lang: { resetZoom: 'Redefinir zoom', resetZoomTitle: 'Voltar ao período completo' }, +}); + +type Props = { group: MetricGroup }; + +export function MetricChart({ group }: Props) { + const theme = useTheme(); + const sync = useChartSync(); + const chartRef = useRef(null); + + const palette: ChartPalette = useMemo( + () => ({ + text: theme.vars.palette.text.primary, + subtext: theme.vars.palette.text.secondary, + divider: theme.vars.palette.divider, + tooltipBg: theme.vars.palette.background.paper, + buttonText: theme.vars.palette.text.primary, + buttonBg: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + buttonHoverBg: varAlpha(theme.vars.palette.grey['500Channel'], 0.32), + }), + [theme] + ); + + const options = useMemo(() => buildChartOptions(group, palette), [group, palette]); + + useEffect(() => { + const chart = chartRef.current?.chart; + + if (!chart || !sync) return; + + return sync.register(chart); + }, [sync, options]); + + return ( + + + + + + + + + + ); +} diff --git a/apps/frontend/src/sections/data/series-stats-row.tsx b/apps/frontend/src/sections/data/series-stats-row.tsx new file mode 100644 index 0000000000..fb1b248d5c --- /dev/null +++ b/apps/frontend/src/sections/data/series-stats-row.tsx @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import type { MetricGroup } from 'src/store/measurements/selectors'; + +import { seriesColor, seriesLabel } from './chart-options'; + +const format = (value: number, metric: MetricGroup['metric']) => + value.toFixed(metric === 'acceleration' ? 4 : 2); + +export function SeriesStatsRow({ group }: { group: MetricGroup }) { + return ( + + {group.series.map((series) => ( + + + + + {seriesLabel(series)} + + + + + {format(series.stats.last, group.metric)} + + {group.unit} + + + + + máx {format(series.stats.max, group.metric)} • méd {format(series.stats.avg, group.metric)} + + + ))} + + ); +} diff --git a/apps/frontend/src/sections/data/use-url-filters.ts b/apps/frontend/src/sections/data/use-url-filters.ts new file mode 100644 index 0000000000..789d4ba938 --- /dev/null +++ b/apps/frontend/src/sections/data/use-url-filters.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router'; + +import { useAppDispatch, useAppSelector } from 'src/store/hooks'; +import { machineSelected } from 'src/store/machines/slice'; +import { isPeriod } from 'src/store/measurements/period'; +import { periodChanged } from 'src/store/measurements/slice'; + +export const MACHINE_PARAM = 'machine'; +export const PERIOD_PARAM = 'period'; + +export function useUrlFilters() { + const dispatch = useAppDispatch(); + const [searchParams, setSearchParams] = useSearchParams(); + + const selectedId = useAppSelector((state) => state.machines.selectedId); + const period = useAppSelector((state) => state.measurements.period); + + const isHydrated = useRef(false); + + useEffect(() => { + if (!isHydrated.current) return; + + const next = new URLSearchParams(searchParams); + + if (selectedId) next.set(MACHINE_PARAM, selectedId); + else next.delete(MACHINE_PARAM); + + next.set(PERIOD_PARAM, period); + + if (next.toString() !== searchParams.toString()) setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams, selectedId, period]); + + useEffect(() => { + const urlMachine = searchParams.get(MACHINE_PARAM); + const urlPeriod = searchParams.get(PERIOD_PARAM); + + if (urlMachine) dispatch(machineSelected(urlMachine)); + if (isPeriod(urlPeriod)) dispatch(periodChanged(urlPeriod)); + + isHydrated.current = true; + }, []); +} diff --git a/apps/frontend/src/store/hooks.ts b/apps/frontend/src/store/hooks.ts new file mode 100644 index 0000000000..793dae89af --- /dev/null +++ b/apps/frontend/src/store/hooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; + +import type { AppDispatch, RootState } from './index'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/apps/frontend/src/store/index.ts b/apps/frontend/src/store/index.ts new file mode 100644 index 0000000000..dee7ab6be9 --- /dev/null +++ b/apps/frontend/src/store/index.ts @@ -0,0 +1,25 @@ +import { configureStore } from '@reduxjs/toolkit'; +import createSagaMiddleware from 'redux-saga'; + +import { rootReducer, type RootState } from './root-reducer'; +import { rootSaga } from './root-saga'; + +export const createStore = (preloadedState?: Partial) => { + const sagaMiddleware = createSagaMiddleware(); + + const store = configureStore({ + reducer: rootReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: false }).concat(sagaMiddleware), + }); + + sagaMiddleware.run(rootSaga); + + return store; +}; + +export const store = createStore(); + +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; +export type { RootState }; diff --git a/apps/frontend/src/store/machines/saga.ts b/apps/frontend/src/store/machines/saga.ts new file mode 100644 index 0000000000..aa93154a64 --- /dev/null +++ b/apps/frontend/src/store/machines/saga.ts @@ -0,0 +1,21 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; + +import { getOrpcErrorMessage, orpcClient } from 'src/lib/orpc-client'; + +import { fetchMachines, fetchMachinesFailed, fetchMachinesSucceeded } from './slice'; + +type MachinesResponse = Awaited>; + +function* fetchMachinesSaga() { + try { + const response: MachinesResponse = yield call(() => orpcClient.machines.list()); + + yield put(fetchMachinesSucceeded(response.data)); + } catch (error) { + yield put(fetchMachinesFailed(getOrpcErrorMessage(error))); + } +} + +export function* watchFetchMachines() { + yield takeLatest(fetchMachines.type, fetchMachinesSaga); +} diff --git a/apps/frontend/src/store/machines/slice.test.ts b/apps/frontend/src/store/machines/slice.test.ts new file mode 100644 index 0000000000..b4932c52d5 --- /dev/null +++ b/apps/frontend/src/store/machines/slice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { machineFixture } from 'src/test/fixtures'; + +import { + fetchMachines, + fetchMachinesFailed, + fetchMachinesSucceeded, + machineSelected, + machinesSlice, +} from './slice'; + +const reducer = machinesSlice.reducer; +const initial = machinesSlice.getInitialState(); + +describe('machinesSlice', () => { + it('entra em loading e limpa o erro anterior ao buscar', () => { + const state = reducer({ ...initial, error: 'falha antiga' }, fetchMachines()); + + expect(state).toMatchObject({ status: 'loading', error: null }); + }); + + it('seleciona a primeira máquina quando ainda não há seleção', () => { + const state = reducer(initial, fetchMachinesSucceeded([machineFixture])); + + expect(state.selectedId).toBe('MCH-001'); + expect(state.status).toBe('succeeded'); + }); + + it('preserva a seleção do usuário ao recarregar a lista', () => { + const outra = { ...machineFixture, id: 'MCH-002' }; + const selected = reducer(initial, machineSelected('MCH-002')); + const state = reducer(selected, fetchMachinesSucceeded([machineFixture, outra])); + + expect(state.selectedId).toBe('MCH-002'); + }); + + it('descarta seleção que não existe na lista, cobrindo link com máquina inválida', () => { + const selected = reducer(initial, machineSelected('NAO-EXISTE')); + const state = reducer(selected, fetchMachinesSucceeded([machineFixture])); + + expect(state.selectedId).toBe('MCH-001'); + }); + + it('não quebra a seleção com lista vazia', () => { + const state = reducer(initial, fetchMachinesSucceeded([])); + + expect(state.selectedId).toBeNull(); + }); + + it('guarda a mensagem de erro', () => { + const state = reducer(initial, fetchMachinesFailed('API fora do ar')); + + expect(state).toMatchObject({ status: 'failed', error: 'API fora do ar' }); + }); +}); diff --git a/apps/frontend/src/store/machines/slice.ts b/apps/frontend/src/store/machines/slice.ts new file mode 100644 index 0000000000..1e283758df --- /dev/null +++ b/apps/frontend/src/store/machines/slice.ts @@ -0,0 +1,47 @@ +import type { Machine } from '@repo/contracts'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import type { RequestStatus } from '../types'; + +export type MachinesState = { + items: Machine[]; + selectedId: string | null; + status: RequestStatus; + error: string | null; +}; + +const initialState: MachinesState = { + items: [], + selectedId: null, + status: 'idle', + error: null, +}; + +export const machinesSlice = createSlice({ + name: 'machines', + initialState, + reducers: { + fetchMachines: (state) => { + state.status = 'loading'; + state.error = null; + }, + fetchMachinesSucceeded: (state, action: PayloadAction) => { + state.status = 'succeeded'; + state.items = action.payload; + + const isSelectionValid = action.payload.some((machine) => machine.id === state.selectedId); + + state.selectedId = isSelectionValid ? state.selectedId : (action.payload[0]?.id ?? null); + }, + fetchMachinesFailed: (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + }, + machineSelected: (state, action: PayloadAction) => { + state.selectedId = action.payload; + }, + }, +}); + +export const { fetchMachines, fetchMachinesSucceeded, fetchMachinesFailed, machineSelected } = + machinesSlice.actions; diff --git a/apps/frontend/src/store/measurements/period.test.ts b/apps/frontend/src/store/measurements/period.test.ts new file mode 100644 index 0000000000..cf20ddf3d4 --- /dev/null +++ b/apps/frontend/src/store/measurements/period.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { periodToRange } from './period'; + +const LAST_READING = '2023-12-12T15:02:42.000Z'; + +describe('periodToRange', () => { + it('não recorta quando o período é "all"', () => { + expect(periodToRange('all', LAST_READING)).toEqual({}); + }); + + it('ancora o recorte na última leitura, não no relógio do usuário', () => { + expect(periodToRange('7d', LAST_READING)).toEqual({ + from: '2023-12-05T15:02:42.000Z', + to: LAST_READING, + }); + }); + + it('conta os dias corretos para 30 dias, atravessando a virada de mês', () => { + expect(periodToRange('30d', LAST_READING)).toEqual({ + from: '2023-11-12T15:02:42.000Z', + to: LAST_READING, + }); + }); + + it('não recorta quando a âncora é ausente ou inválida', () => { + expect(periodToRange('7d', undefined)).toEqual({}); + expect(periodToRange('7d', 'não é data')).toEqual({}); + }); +}); diff --git a/apps/frontend/src/store/measurements/period.ts b/apps/frontend/src/store/measurements/period.ts new file mode 100644 index 0000000000..0cfe63cece --- /dev/null +++ b/apps/frontend/src/store/measurements/period.ts @@ -0,0 +1,26 @@ +export const PERIODS = ['7d', '30d', 'all'] as const; + +export type Period = (typeof PERIODS)[number]; + +export const PERIOD_LABELS: Record = { + '7d': '7 dias', + '30d': '30 dias', + all: 'Tudo', +}; + +const DAYS_BY_PERIOD: Record, number> = { '7d': 7, '30d': 30 }; + +export const isPeriod = (value: unknown): value is Period => PERIODS.includes(value as Period); + +export const periodToRange = (period: Period, lastReadingAt?: string): { from?: string; to?: string } => { + if (period === 'all' || !lastReadingAt) return {}; + + const anchor = new Date(lastReadingAt); + + if (Number.isNaN(anchor.getTime())) return {}; + + const from = new Date(anchor); + from.setUTCDate(from.getUTCDate() - DAYS_BY_PERIOD[period]); + + return { from: from.toISOString(), to: anchor.toISOString() }; +}; diff --git a/apps/frontend/src/store/measurements/saga.ts b/apps/frontend/src/store/measurements/saga.ts new file mode 100644 index 0000000000..bc31f80cd8 --- /dev/null +++ b/apps/frontend/src/store/measurements/saga.ts @@ -0,0 +1,36 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; + +import { getOrpcErrorMessage, orpcClient } from 'src/lib/orpc-client'; + +import type { RootState } from '../root-reducer'; +import { periodToRange } from './period'; +import { fetchMeasurements, fetchMeasurementsFailed, fetchMeasurementsSucceeded } from './slice'; + +type MeasurementsResponse = Awaited>; + +function* fetchMeasurementsSaga({ payload }: PayloadAction<{ machineId: string }>) { + try { + const { machineId } = payload; + + const period: RootState['measurements']['period'] = yield select( + (state: RootState) => state.measurements.period + ); + + const lastReadingAt: string | undefined = yield select( + (state: RootState) => state.machines.items.find((machine) => machine.id === machineId)?.lastReadingAt + ); + + const response: MeasurementsResponse = yield call(() => + orpcClient.measurements.list({ machineId, ...periodToRange(period, lastReadingAt) }) + ); + + yield put(fetchMeasurementsSucceeded(response.series)); + } catch (error) { + yield put(fetchMeasurementsFailed(getOrpcErrorMessage(error))); + } +} + +export function* watchFetchMeasurements() { + yield takeLatest(fetchMeasurements.type, fetchMeasurementsSaga); +} diff --git a/apps/frontend/src/store/measurements/selectors.test.ts b/apps/frontend/src/store/measurements/selectors.test.ts new file mode 100644 index 0000000000..857e1c8783 --- /dev/null +++ b/apps/frontend/src/store/measurements/selectors.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { machineFixture, seriesFixture } from 'src/test/fixtures'; + +import type { RootState } from '../root-reducer'; +import { selectMetricGroups, selectSelectedMachine } from './selectors'; + +const makeState = (overrides: Partial = {}): RootState => + ({ + machines: { items: [machineFixture], selectedId: 'MCH-001', status: 'succeeded', error: null }, + measurements: { series: seriesFixture, period: 'all', status: 'succeeded', error: null }, + ...overrides, + }) as RootState; + +describe('selectMetricGroups', () => { + it('agrupa as séries por grandeza, um grupo por gráfico', () => { + const groups = selectMetricGroups(makeState()); + + expect(groups.map((group) => group.metric)).toEqual(['acceleration', 'velocity', 'temperature']); + expect(groups[0].series).toHaveLength(2); + expect(groups[2].series).toHaveLength(1); + }); + + it('usa a unidade das séries do grupo', () => { + const groups = selectMetricGroups(makeState()); + + expect(groups.map((group) => group.unit)).toEqual(['g', 'mm/s', '°C']); + }); + + it('omite grandezas sem série em vez de criar gráfico vazio', () => { + const state = makeState({ + measurements: { + series: seriesFixture.filter((series) => series.metric === 'temperature'), + period: 'all', + status: 'succeeded', + error: null, + }, + }); + + expect(selectMetricGroups(state).map((group) => group.metric)).toEqual(['temperature']); + }); + + it('devolve lista vazia sem séries', () => { + const state = makeState({ + measurements: { series: [], period: 'all', status: 'succeeded', error: null }, + }); + + expect(selectMetricGroups(state)).toEqual([]); + }); +}); + +describe('selectSelectedMachine', () => { + it('resolve a máquina selecionada', () => { + expect(selectSelectedMachine(makeState())?.name).toBe('Ventilador de Exaustão 01'); + }); + + it('devolve null quando o id selecionado não existe na lista', () => { + const state = makeState({ + machines: { items: [machineFixture], selectedId: 'MCH-999', status: 'succeeded', error: null }, + }); + + expect(selectSelectedMachine(state)).toBeNull(); + }); +}); diff --git a/apps/frontend/src/store/measurements/selectors.ts b/apps/frontend/src/store/measurements/selectors.ts new file mode 100644 index 0000000000..6ba80b5318 --- /dev/null +++ b/apps/frontend/src/store/measurements/selectors.ts @@ -0,0 +1,41 @@ +import type { Metric, Series } from '@repo/contracts'; +import { createSelector } from '@reduxjs/toolkit'; + +import type { RootState } from '../root-reducer'; + +export type MetricGroup = { + metric: Metric; + label: string; + unit: string; + series: Series[]; +}; + +const METRIC_ORDER: Metric[] = ['acceleration', 'velocity', 'temperature']; + +export const METRIC_LABELS: Record = { + acceleration: 'Aceleração RMS', + velocity: 'Velocidade RMS', + temperature: 'Temperatura', +}; + +const selectSeries = (state: RootState) => state.measurements.series; + +export const selectMetricGroups = createSelector([selectSeries], (series): MetricGroup[] => + METRIC_ORDER.flatMap((metric) => { + const matching = series.filter((item) => item.metric === metric); + + if (matching.length === 0) return []; + + return [ + { + metric, + label: METRIC_LABELS[metric], + unit: matching[0].unit, + series: matching, + }, + ]; + }) +); + +export const selectSelectedMachine = (state: RootState) => + state.machines.items.find((machine) => machine.id === state.machines.selectedId) ?? null; diff --git a/apps/frontend/src/store/measurements/slice.test.ts b/apps/frontend/src/store/measurements/slice.test.ts new file mode 100644 index 0000000000..7978593374 --- /dev/null +++ b/apps/frontend/src/store/measurements/slice.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { seriesFixture } from 'src/test/fixtures'; + +import { + fetchMeasurements, + fetchMeasurementsFailed, + fetchMeasurementsSucceeded, + measurementsSlice, + periodChanged, +} from './slice'; + +const reducer = measurementsSlice.reducer; +const initial = measurementsSlice.getInitialState(); + +describe('measurementsSlice', () => { + it('começa exibindo a série completa', () => { + expect(initial.period).toBe('all'); + }); + + it('entra em loading ao buscar', () => { + const state = reducer(initial, fetchMeasurements({ machineId: 'MCH-001' })); + + expect(state).toMatchObject({ status: 'loading', error: null }); + }); + + it('guarda as séries recebidas', () => { + const state = reducer(initial, fetchMeasurementsSucceeded(seriesFixture)); + + expect(state.series).toHaveLength(4); + expect(state.status).toBe('succeeded'); + }); + + it('descarta as séries antigas ao falhar, para não exibir dado de outro recorte', () => { + const loaded = reducer(initial, fetchMeasurementsSucceeded(seriesFixture)); + const state = reducer(loaded, fetchMeasurementsFailed('timeout')); + + expect(state.series).toEqual([]); + expect(state).toMatchObject({ status: 'failed', error: 'timeout' }); + }); + + it('troca o período sem mexer nas séries já carregadas', () => { + const loaded = reducer(initial, fetchMeasurementsSucceeded(seriesFixture)); + const state = reducer(loaded, periodChanged('7d')); + + expect(state.period).toBe('7d'); + expect(state.series).toHaveLength(4); + }); +}); diff --git a/apps/frontend/src/store/measurements/slice.ts b/apps/frontend/src/store/measurements/slice.ts new file mode 100644 index 0000000000..3e3072baf1 --- /dev/null +++ b/apps/frontend/src/store/measurements/slice.ts @@ -0,0 +1,49 @@ +import type { Series } from '@repo/contracts'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import type { RequestStatus } from '../types'; +import type { Period } from './period'; + +export type MeasurementsState = { + series: Series[]; + period: Period; + status: RequestStatus; + error: string | null; +}; + +const initialState: MeasurementsState = { + series: [], + period: 'all', + status: 'idle', + error: null, +}; + +export const measurementsSlice = createSlice({ + name: 'measurements', + initialState, + reducers: { + fetchMeasurements: (state, _action: PayloadAction<{ machineId: string }>) => { + state.status = 'loading'; + state.error = null; + }, + fetchMeasurementsSucceeded: (state, action: PayloadAction) => { + state.status = 'succeeded'; + state.series = action.payload; + }, + fetchMeasurementsFailed: (state, action: PayloadAction) => { + state.status = 'failed'; + state.error = action.payload; + state.series = []; + }, + periodChanged: (state, action: PayloadAction) => { + state.period = action.payload; + }, + }, +}); + +export const { + fetchMeasurements, + fetchMeasurementsSucceeded, + fetchMeasurementsFailed, + periodChanged, +} = measurementsSlice.actions; diff --git a/apps/frontend/src/store/root-reducer.ts b/apps/frontend/src/store/root-reducer.ts new file mode 100644 index 0000000000..3a44db82c3 --- /dev/null +++ b/apps/frontend/src/store/root-reducer.ts @@ -0,0 +1,11 @@ +import { combineReducers } from '@reduxjs/toolkit'; + +import { machinesSlice } from './machines/slice'; +import { measurementsSlice } from './measurements/slice'; + +export const rootReducer = combineReducers({ + [machinesSlice.name]: machinesSlice.reducer, + [measurementsSlice.name]: measurementsSlice.reducer, +}); + +export type RootState = ReturnType; diff --git a/apps/frontend/src/store/root-saga.ts b/apps/frontend/src/store/root-saga.ts new file mode 100644 index 0000000000..241fc51c70 --- /dev/null +++ b/apps/frontend/src/store/root-saga.ts @@ -0,0 +1,8 @@ +import { all, fork } from 'redux-saga/effects'; + +import { watchFetchMachines } from './machines/saga'; +import { watchFetchMeasurements } from './measurements/saga'; + +export function* rootSaga() { + yield all([fork(watchFetchMachines), fork(watchFetchMeasurements)]); +} diff --git a/apps/frontend/src/store/types.ts b/apps/frontend/src/store/types.ts new file mode 100644 index 0000000000..580dc71ffa --- /dev/null +++ b/apps/frontend/src/store/types.ts @@ -0,0 +1 @@ +export type RequestStatus = 'idle' | 'loading' | 'succeeded' | 'failed'; diff --git a/apps/frontend/src/test/fixtures.ts b/apps/frontend/src/test/fixtures.ts new file mode 100644 index 0000000000..254d1d9e1d --- /dev/null +++ b/apps/frontend/src/test/fixtures.ts @@ -0,0 +1,46 @@ +import type { Machine, Metric, Series } from '@repo/contracts'; + +export const machineFixture: Machine = { + id: 'MCH-001', + name: 'Ventilador de Exaustão 01', + type: 'Motor elétrico', + manufacturer: 'WEG', + rpm: 1780, + status: 'alert', + sensor: { + id: 'SNR-8842', + model: 'DynaLogger TcAg', + serialNumber: 'HF-8842-2023', + position: 'Mancal dianteiro', + }, + lastReadingAt: '2023-12-12T15:02:42.000Z', +}; + +export const makeSeries = ( + id: string, + metric: Metric, + axis: Series['axis'], + values: number[] = [1, 2, 3] +): Series => ({ + id, + metric, + axis, + unit: metric === 'temperature' ? '°C' : metric === 'velocity' ? 'mm/s' : 'g', + points: values.map((value, index) => ({ + datetime: new Date(Date.UTC(2023, 10, 7 + index)).toISOString(), + max: value, + })), + stats: { + min: Math.min(...values), + max: Math.max(...values), + avg: values.reduce((sum, value) => sum + value, 0) / values.length, + last: values[values.length - 1], + }, +}); + +export const seriesFixture: Series[] = [ + makeSeries('accelerationRms/x', 'acceleration', 'x'), + makeSeries('accelerationRms/y', 'acceleration', 'y'), + makeSeries('velocityRms/x', 'velocity', 'x'), + makeSeries('temperature', 'temperature', null), +]; diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts new file mode 100644 index 0000000000..bb02c60cd0 --- /dev/null +++ b/apps/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/apps/frontend/src/theme/core/components/accordion.tsx b/apps/frontend/src/theme/core/components/accordion.tsx new file mode 100644 index 0000000000..9eae9b51a3 --- /dev/null +++ b/apps/frontend/src/theme/core/components/accordion.tsx @@ -0,0 +1,216 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { AccordionSummaryClassKey } from '@mui/material/AccordionSummary'; +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import SvgIcon from '@mui/material/SvgIcon'; +import { accordionClasses } from '@mui/material/Accordion'; +import { accordionSummaryClasses } from '@mui/material/AccordionSummary'; +import { accordionDetailsClasses } from '@mui/material/AccordionDetails'; + +// ---------------------------------------------------------------------- + +type AccordionVariants = ComponentsVariants['MuiAccordion']; + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ + +const PlusIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/mingcute/add-line/ + + + + + + +); + +const MinusIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/mingcute/minimize-line/ + + + + + + +); + +const iconClasses = { + container: 'accordion__icon__container', + plus: 'accordion__icon__plus', + minus: 'accordion__icon__minus', +}; + +const getExpandIconStyles = (theme: Theme): Record => { + const resetTransform: Record = { + default: { + transition: 'inherit', + transform: 'rotate(0deg)', + }, + expanded: { + transform: 'rotate(-180deg)', + }, + }; + + const iconContainerStyles: CSSObject = { + width: 24, + height: 24, + display: 'flex', + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + }; + + const iconStyles: CSSObject = { + width: 18, + height: 18, + position: 'absolute', + transition: theme.transitions.create(['transform', 'opacity'], { + easing: theme.transitions.easing.easeIn, + duration: theme.transitions.duration.shortest, + }), + }; + + return { + [`& .${iconClasses.container}`]: { ...resetTransform.default, ...iconContainerStyles }, + [`& .${iconClasses.plus}`]: { ...iconStyles, transform: 'scale(1)', opacity: 1 }, + [`& .${iconClasses.minus}`]: { ...iconStyles, transform: 'scale(0.4)', opacity: 0 }, + [`&.${accordionSummaryClasses.expanded}`]: { + [`& .${iconClasses.container}`]: resetTransform.expanded, + [`& .${iconClasses.plus}`]: { transform: 'scale(0.4)', opacity: 0 }, + [`& .${iconClasses.minus}`]: { transform: 'scale(1)', opacity: 1 }, + }, + }; +}; + +const ExpandIcon = (props: BoxProps) => ( + + + + +); + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const expandedVariants = [ + { + props: (props) => !props.disableGutters && !!props.expanded, + style: ({ theme }) => ({ + boxShadow: theme.vars.customShadows.z8, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.vars.palette.background.paper, + }), + }, +] satisfies AccordionVariants; + +const disableGuttersVariants = [ + { + props: (props) => !!props.disableGutters, + style: ({ theme }) => ({ + borderBottom: `solid 1px ${theme.vars.palette.divider}`, + '&:last-of-type': { borderBottom: 'none' }, + '&::before': { display: 'none' }, // Hide the border + [`& .${accordionSummaryClasses.root}`]: { + paddingLeft: 0, + paddingRight: 0, + }, + [`& .${accordionDetailsClasses.root}`]: { + paddingLeft: 0, + paddingRight: 0, + }, + }), + }, +] satisfies AccordionVariants; + +const disableVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${accordionClasses.disabled}`]: { + backgroundColor: 'transparent', + [`& .${accordionDetailsClasses.root}`]: { + opacity: theme.vars.palette.action.disabledOpacity, + }, + }, + }), + }, +] satisfies AccordionVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiAccordion: Components['MuiAccordion'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + square: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + backgroundColor: 'transparent', + variants: [...expandedVariants, ...disableGuttersVariants, ...disableVariants], + }, + }, +}; + +const sizingReset: Partial> = { + root: { + minHeight: 'auto', + [`&.${accordionSummaryClasses.expanded}`]: { + minHeight: 'inherit', + }, + }, + content: { + margin: 0, + [`&.${accordionSummaryClasses.expanded}`]: { + margin: 'inherit', + }, + }, +}; + +const MuiAccordionSummary: Components['MuiAccordionSummary'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + expandIcon: , + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...sizingReset.root, + padding: theme.spacing(2, 1, 2, 2), + }), + content: { + ...sizingReset.content, + }, + expandIconWrapper: ({ theme }) => ({ + ...getExpandIconStyles(theme), + color: 'inherit', + alignSelf: 'flex-start', + marginLeft: theme.spacing(2), + }), + }, +}; + +const MuiAccordionDetails: Components['MuiAccordionDetails'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + paddingTop: 0, + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const accordion: Components = { + MuiAccordion, + MuiAccordionSummary, + MuiAccordionDetails, +}; diff --git a/apps/frontend/src/theme/core/components/alert.tsx b/apps/frontend/src/theme/core/components/alert.tsx new file mode 100644 index 0000000000..d2ba174c15 --- /dev/null +++ b/apps/frontend/src/theme/core/components/alert.tsx @@ -0,0 +1,151 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha, parseCssVar } from 'minimal-shared/utils'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const SEVERITIES = ['info', 'success', 'warning', 'error'] as const; + +type AlertVariants = ComponentsVariants['MuiAlert']; + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const InfoIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/info-circle-bold/ + + + +); + +const SuccessIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/check-circle-bold/ + + + +); + +const WarningIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/danger-triangle-bold/ + + + +); + +const ErrorIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/danger-bold/ + + + +); + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const standardVariants = [ + ...(SEVERITIES.map((colorKey) => ({ + props: (props) => props.variant === 'standard' && props.severity === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].darker, + backgroundColor: theme.vars.palette[colorKey].lighter, + ...theme.applyStyles('dark', { + color: theme.vars.palette[colorKey].lighter, + backgroundColor: theme.vars.palette[colorKey].darker, + }), + }), + })) satisfies AlertVariants), +] satisfies AlertVariants; + +const filledVariants = [ + ...(SEVERITIES.map((colorKey) => ({ + props: (props) => props.variant === 'filled' && props.severity === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].contrastText, + }), + })) satisfies AlertVariants), +] satisfies AlertVariants; + +const outlinedVariants = [ + ...(SEVERITIES.map((colorKey) => ({ + props: (props) => props.variant === 'outlined' && props.severity === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].dark, + backgroundColor: varAlpha(theme.vars.palette[colorKey].mainChannel, 0.08), + border: `solid 1px ${varAlpha(theme.vars.palette[colorKey].mainChannel, 0.16)}`, + ...theme.applyStyles('dark', { + color: theme.vars.palette[colorKey].light, + }), + }), + })) satisfies AlertVariants), +] satisfies AlertVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiAlert: Components['MuiAlert'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + iconMapping: { + info: , + error: , + success: , + warning: , + }, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [...standardVariants, ...filledVariants, ...outlinedVariants], + }, + icon: ({ theme }) => ({ + opacity: 1, + ...theme.applyStyles('dark', { + [parseCssVar(theme.vars.palette.Alert.infoIconColor)]: theme.vars.palette.info.light, + [parseCssVar(theme.vars.palette.Alert.errorIconColor)]: theme.vars.palette.error.light, + [parseCssVar(theme.vars.palette.Alert.successIconColor)]: theme.vars.palette.success.light, + [parseCssVar(theme.vars.palette.Alert.warningIconColor)]: theme.vars.palette.warning.light, + }), + }), + }, +}; + +const MuiAlertTitle: Components['MuiAlertTitle'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + marginBottom: theme.spacing(0.5), + fontWeight: theme.typography.fontWeightSemiBold, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const alert: Components = { + MuiAlert, + MuiAlertTitle, +}; diff --git a/apps/frontend/src/theme/core/components/appbar.tsx b/apps/frontend/src/theme/core/components/appbar.tsx new file mode 100644 index 0000000000..fba9ad46c6 --- /dev/null +++ b/apps/frontend/src/theme/core/components/appbar.tsx @@ -0,0 +1,21 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiAppBar: Components['MuiAppBar'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'transparent', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { boxShadow: 'none' }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const appBar: Components = { + MuiAppBar, +}; diff --git a/apps/frontend/src/theme/core/components/autocomplete.tsx b/apps/frontend/src/theme/core/components/autocomplete.tsx new file mode 100644 index 0000000000..73ecd68e08 --- /dev/null +++ b/apps/frontend/src/theme/core/components/autocomplete.tsx @@ -0,0 +1,69 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import SvgIcon, { svgIconClasses } from '@mui/material/SvgIcon'; +import { autocompleteClasses } from '@mui/material/Autocomplete'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const ArrowDownIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/eva/arrow-ios-downward-fill/ + + + +); + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiAutocomplete: Components['MuiAutocomplete'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + popupIcon: , + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [`& span.${autocompleteClasses.tag}`]: { + ...theme.typography.subtitle2, + height: 24, + minWidth: 24, + lineHeight: '24px', + textAlign: 'center', + padding: theme.spacing(0, 0.75), + color: theme.vars.palette.text.secondary, + borderRadius: theme.shape.borderRadius, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + }, + }), + paper: ({ theme }) => ({ + ...theme.mixins.paperStyles(theme, { dropdown: true }), + }), + listbox: ({ theme }) => ({ + padding: 0, + [`& .${autocompleteClasses.option}`]: { + ...theme.mixins.menuItemStyles(theme), + }, + }), + endAdornment: { + [`& .${svgIconClasses.root}`]: { + fontSize: 18, + }, + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const autocomplete: Components = { + MuiAutocomplete, +}; diff --git a/apps/frontend/src/theme/core/components/avatar.tsx b/apps/frontend/src/theme/core/components/avatar.tsx new file mode 100644 index 0000000000..9fc8afbbb6 --- /dev/null +++ b/apps/frontend/src/theme/core/components/avatar.tsx @@ -0,0 +1,173 @@ +import type { AvatarGroupClassKey } from '@mui/material/AvatarGroup'; +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; +import type { PaletteColorKey } from '../palette'; + +import { parseCssVar } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type AvatarGroupExtendVariant = { compact: true }; +export type AvatarExtendColor = { + color?: PaletteColorKey | 'default' | 'inherit'; +}; + +type AvatarVariants = ComponentsVariants['MuiAvatar']; +type AvatarGroupVariants = ComponentsVariants['MuiAvatarGroup']; + +const baseColors = ['default', 'inherit'] as const; +const allColors = [...baseColors, ...colorKeys.palette] as const; + +export function getAvatarColor( + inputValue?: string, + fallback: AvatarExtendColor['color'] = 'default' +): string { + if (!inputValue?.trim()) { + return fallback; + } + + const firstChar = inputValue.trim()[0].toLowerCase(); + + // Only handle alphabet characters a-z + if (!/[a-z]/.test(firstChar)) { + return fallback; + } + + const alphabetIndex = firstChar.charCodeAt(0) - 'a'.charCodeAt(0); // 0 for 'a', 25 for 'z' + const colorIndex = alphabetIndex % allColors.length; + + return allColors[colorIndex] || fallback; +} + +const customRenderSurplus = (surplus: number) => ( + ({ + width: 1, + height: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + color: theme.vars.palette.primary.dark, + backgroundColor: theme.vars.palette.primary.lighter, + fontSize: { + '@': theme.typography.pxToRem(11), + '@32': theme.typography.pxToRem(12), + '@36': theme.typography.pxToRem(13), + '@40': theme.typography.pxToRem(14), + '@64': theme.typography.pxToRem(18), + }, + }), + ]} + > + +{surplus} + +); + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + { + props: {}, + style: ({ theme }) => ({ + color: theme.vars.palette.action.active, + [parseCssVar(theme.vars.palette.Avatar.defaultBg)]: theme.vars.palette.grey[300], + ...theme.applyStyles('dark', { + [parseCssVar(theme.vars.palette.Avatar.defaultBg)]: theme.vars.palette.grey[700], + }), + }), + }, + { + props: (props) => + props.color === 'inherit' || (!!props.alt && getAvatarColor(props.alt) === 'inherit'), + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'inherit'), + }), + }, + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => + props.color === colorKey || (!!props.alt && getAvatarColor(props.alt) === colorKey), + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].contrastText, + backgroundColor: theme.vars.palette[colorKey].main, + }), + })) satisfies AvatarVariants), +] satisfies AvatarVariants; + +const avatarGroupVariants = { + root: [ + { + props: (props) => props.variant === 'compact', + style: { width: 40, height: 40, position: 'relative' }, + }, + ], + avatar: [ + { + props: (props) => props.variant === 'compact', + style: { + margin: 0, + width: 28, + height: 28, + position: 'absolute', + '&:first-of-type': { left: 0, bottom: 0, zIndex: 9 }, + '&:last-of-type': { top: 0, right: 0 }, + }, + }, + ], +} satisfies Record; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiAvatar: Components['MuiAvatar'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + containerType: 'inline-size', + fontSize: theme.typography.pxToRem(18), + fontWeight: theme.typography.fontWeightMedium, + }), + colorDefault: { + variants: [...colorVariants], + }, + rounded: ({ theme }) => ({ + borderRadius: Number(theme.shape.borderRadius) * 1.5, + }), + }, +}; + +const MuiAvatarGroup: Components['MuiAvatarGroup'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + max: 4, + renderSurplus: (surplus: number) => customRenderSurplus(surplus), + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + justifyContent: 'flex-end', + variants: [...avatarGroupVariants.root], + }, + avatar: { + variants: [...avatarGroupVariants.avatar], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const avatar: Components = { + MuiAvatar, + MuiAvatarGroup, +}; diff --git a/apps/frontend/src/theme/core/components/backdrop.tsx b/apps/frontend/src/theme/core/components/backdrop.tsx new file mode 100644 index 0000000000..0bd4b857df --- /dev/null +++ b/apps/frontend/src/theme/core/components/backdrop.tsx @@ -0,0 +1,28 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +const MuiBackdrop: Components['MuiBackdrop'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + variants: [ + { + props: (props) => !props.invisible, + style: { + backgroundColor: varAlpha(theme.vars.palette.grey['800Channel'], 0.48), + }, + }, + ], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const backdrop: Components = { + MuiBackdrop, +}; diff --git a/apps/frontend/src/theme/core/components/badge.tsx b/apps/frontend/src/theme/core/components/badge.tsx new file mode 100644 index 0000000000..9d38e7d294 --- /dev/null +++ b/apps/frontend/src/theme/core/components/badge.tsx @@ -0,0 +1,104 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type BadgeExtendVariant = { + always: true; + busy: true; + online: true; + offline: true; + invisible: true; +}; + +type BadgeVariants = ComponentsVariants['MuiBadge']; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + { + props: (props) => props.color === 'default', + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'default'), + }), + }, +] satisfies BadgeVariants; + +const statusVariants = [ + { + props: (props) => ['online', 'always', 'busy', 'offline'].includes(props.variant as string), + style: ({ theme }) => ({ + width: 10, + height: 10, + padding: 0, + top: 'auto', + right: '14%', + bottom: '14%', + minWidth: 'auto', + transform: 'scale(1) translate(50%, 50%)', + '&::before, &::after': { + content: "''", + borderRadius: 1, + backgroundColor: theme.vars.palette.common.white, + }, + }), + }, + { + props: (props) => props.variant === 'online', + style: ({ theme }) => ({ + backgroundColor: theme.vars.palette.success.main, + }), + }, + { + props: (props) => props.variant === 'always', + style: ({ theme }) => ({ + backgroundColor: theme.vars.palette.warning.main, + '&::before': { width: 2, height: 4, transform: 'translate(1px, -1px)' }, + '&::after': { width: 2, height: 4, transform: 'translate(0, 1px) rotate(125deg)' }, + }), + }, + { + props: (props) => props.variant === 'busy', + style: ({ theme }) => ({ + backgroundColor: theme.vars.palette.error.main, + '&::before': { width: 6, height: 2 }, + }), + }, + { + props: (props) => props.variant === 'offline', + style: ({ theme }) => ({ + backgroundColor: theme.vars.palette.text.disabled, + '&::before': { width: 6, height: 6, borderRadius: '50%' }, + }), + }, + { + props: (props) => props.variant === 'invisible', + style: { + display: 'none', + }, + }, +] satisfies BadgeVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiBadge: Components['MuiBadge'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + dot: { borderRadius: '50%' }, + badge: { + variants: [...colorVariants, ...statusVariants], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const badge: Components = { + MuiBadge, +}; diff --git a/apps/frontend/src/theme/core/components/breadcrumbs.tsx b/apps/frontend/src/theme/core/components/breadcrumbs.tsx new file mode 100644 index 0000000000..535b4e7143 --- /dev/null +++ b/apps/frontend/src/theme/core/components/breadcrumbs.tsx @@ -0,0 +1,25 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiBreadcrumbs: Components['MuiBreadcrumbs'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + ol: ({ theme }) => ({ + rowGap: theme.spacing(0.5), + columnGap: theme.spacing(2), + }), + li: ({ theme }) => ({ + display: 'inline-flex', + '& > *': { ...theme.typography.body2 }, + }), + separator: { margin: 0 }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const breadcrumbs: Components = { + MuiBreadcrumbs, +}; diff --git a/apps/frontend/src/theme/core/components/button-fab.tsx b/apps/frontend/src/theme/core/components/button-fab.tsx new file mode 100644 index 0000000000..955a6e0cac --- /dev/null +++ b/apps/frontend/src/theme/core/components/button-fab.tsx @@ -0,0 +1,215 @@ +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import { pxToRem, varAlpha } from 'minimal-shared/utils'; + +import { fabClasses } from '@mui/material/Fab'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type FabExtendVariant = { + outlined: true; + outlinedExtended: true; + soft: true; + softExtended: true; +}; +export type FabExtendColor = { black: true; white: true }; + +type FabVariants = ComponentsVariants['MuiFab']; + +const baseColors = ['default', 'inherit'] as const; +const allColors = [...baseColors, ...colorKeys.palette, ...colorKeys.common] as const; + +const VARIANTS = { + filled: ['circular', 'extended'], + outlined: ['outlined', 'outlinedExtended'], + soft: ['soft', 'softExtended'], + extended: ['extended', 'outlinedExtended', 'softExtended'], +} as const; +const DIMENSIONS: Record = { + extendedSmall: { + '--size': '36px', + padding: '4px 8px', + fontSize: pxToRem(13), + lineHeight: 22 / 13, + }, + extendedMedium: { + '--size': '40px', + padding: '6px 12px', + fontSize: pxToRem(14), + lineHeight: 24 / 14, + }, + extendedLarge: { + '--size': '48px', + padding: '8px 16px', + fontSize: pxToRem(15), + lineHeight: 26 / 15, + }, +}; + +function isVariant(allowed: readonly T[], variant?: string): variant is T { + return !!variant && allowed.includes(variant as T); +} + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const filledVariants = [ + { + props: (props) => isVariant(VARIANTS.filled, props.variant) && props.color === 'default', + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'default', { hover: true }), + boxShadow: theme.vars.customShadows.z8, + }), + }, + { + props: (props) => isVariant(VARIANTS.filled, props.variant) && props.color === 'inherit', + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'inherit', { hover: true }), + boxShadow: theme.vars.customShadows.z8, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => isVariant(VARIANTS.filled, props.variant) && props.color === colorKey, + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, colorKey, { hover: true }), + boxShadow: theme.vars.customShadows.z8, + }), + })) satisfies FabVariants), + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => isVariant(VARIANTS.filled, props.variant) && props.color === colorKey, + style: ({ theme }) => ({ + boxShadow: theme.vars.customShadows[colorKey], + }), + })) satisfies FabVariants), +] satisfies FabVariants; + +const outlinedVariants = [ + { + props: (props) => isVariant(VARIANTS.outlined, props.variant), + style: ({ theme }) => ({ + borderWidth: 1, + boxShadow: 'none', + borderStyle: 'solid', + backgroundColor: 'transparent', + borderColor: varAlpha('currentColor', theme.vars.opacity.outlined.border), + '&:hover': { + borderColor: 'currentColor', + boxShadow: '0 0 0 0.75px currentColor', + backgroundColor: varAlpha('currentColor', theme.vars.palette.action.hoverOpacity), + }, + }), + }, + { + props: (props) => + isVariant(VARIANTS.outlined, props.variant) && + (props.color === 'default' || props.color === 'inherit'), + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + '&:hover': { + backgroundColor: theme.vars.palette.action.hover, + }, + }), + }, + { + props: (props) => isVariant(VARIANTS.outlined, props.variant) && props.color === 'default', + style: ({ theme }) => ({ + color: theme.vars.palette.action.active, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => isVariant(VARIANTS.outlined, props.variant) && props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette.common[colorKey], + }), + })) satisfies FabVariants), + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => isVariant(VARIANTS.outlined, props.variant) && props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].main, + }), + })) satisfies FabVariants), +] satisfies FabVariants; + +const softVariants = [ + ...(allColors.map((colorKey) => ({ + props: (props) => isVariant(VARIANTS.soft, props.variant) && props.color === colorKey, + style: ({ theme }) => ({ + ...theme.mixins.softStyles(theme, colorKey, { hover: true }), + }), + })) satisfies FabVariants), +] satisfies FabVariants; + +const sizeVariants = [ + { + props: (props) => isVariant(VARIANTS.extended, props.variant), + style: ({ theme }) => ({ + width: 'auto', + height: 'auto', + gap: theme.spacing(1), + minWidth: 'var(--size)', + minHeight: 'var(--size)', + borderRadius: 'calc(var(--size) / 2)', + }), + }, + { + props: (props) => isVariant(VARIANTS.extended, props.variant) && props.size === 'small', + style: { ...DIMENSIONS.extendedSmall }, + }, + { + props: (props) => isVariant(VARIANTS.extended, props.variant) && props.size === 'medium', + style: { ...DIMENSIONS.extendedMedium }, + }, + { + props: (props) => isVariant(VARIANTS.extended, props.variant) && props.size === 'large', + style: { ...DIMENSIONS.extendedLarge }, + }, +] satisfies FabVariants; + +const disabledVariants = [ + { + props: (props) => isVariant(VARIANTS.outlined, props.variant), + style: ({ theme }) => ({ + [`&.${fabClasses.disabled}`]: { + backgroundColor: 'transparent', + borderColor: theme.vars.palette.action.disabledBackground, + }, + }), + }, +] satisfies FabVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiFab: Components['MuiFab'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'primary', + size: 'medium', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + '&:hover': { boxShadow: 'none' }, + variants: [ + ...filledVariants, + ...outlinedVariants, + ...softVariants, + ...sizeVariants, + ...disabledVariants, + ], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const fab: Components = { + MuiFab, +}; diff --git a/apps/frontend/src/theme/core/components/button-group.tsx b/apps/frontend/src/theme/core/components/button-group.tsx new file mode 100644 index 0000000000..3b224ef78c --- /dev/null +++ b/apps/frontend/src/theme/core/components/button-group.tsx @@ -0,0 +1,122 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { buttonGroupClasses } from '@mui/material/ButtonGroup'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type ButtonGroupExtendVariant = { soft: true }; +export type ButtonGroupExtendColor = { black: true; white: true }; + +type ButtonGroupVariants = ComponentsVariants['MuiButtonGroup']; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const containedVariants = [ + { + props: (props) => props.variant === 'contained', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + }), + }, + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => props.variant === 'contained' && props.color === colorKey, + style: ({ theme }) => ({ + borderColor: varAlpha( + theme.vars.palette[colorKey].darkChannel, + theme.vars.opacity.outlined.border + ), + }), + })) satisfies ButtonGroupVariants), +] satisfies ButtonGroupVariants; + +const textVariants = [ + { + props: (props) => props.variant === 'text', + style: ({ theme }) => ({ + borderColor: varAlpha('currentColor', theme.vars.opacity.outlined.border), + }), + }, + { + props: (props) => props.variant === 'text' && props.color === 'inherit', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + }), + }, +] satisfies ButtonGroupVariants; + +const softVariants = [ + { + props: (props) => props.variant === 'soft', + style: ({ theme }) => ({ + borderStyle: 'solid', + borderColor: varAlpha('currentColor', theme.vars.opacity.soft.border), + }), + }, + { + props: (props) => props.variant === 'soft' && props.color === 'inherit', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + }), + }, +] satisfies ButtonGroupVariants; + +const firstButtonVariants = [ + { + props: (props) => props.variant === 'soft' && props.orientation === 'horizontal', + style: { borderRightWidth: 1 }, + }, + { + props: (props) => props.variant === 'soft' && props.orientation === 'vertical', + style: { borderBottomWidth: 1 }, + }, +] satisfies ButtonGroupVariants; + +const disabledVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${buttonGroupClasses.disabled}`]: { + borderColor: theme.vars.palette.action.disabledBackground, + }, + }), + }, +] satisfies ButtonGroupVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiButtonGroup: Components['MuiButtonGroup'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'inherit', + disableElevation: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + grouped: { + variants: [...containedVariants, ...textVariants, ...softVariants, ...disabledVariants], + }, + firstButton: { + variants: [...firstButtonVariants], + }, + middleButton: { + variants: [...firstButtonVariants], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const buttonGroup: Components = { + MuiButtonGroup, +}; diff --git a/apps/frontend/src/theme/core/components/button-icon.tsx b/apps/frontend/src/theme/core/components/button-icon.tsx new file mode 100644 index 0000000000..46dc24d66f --- /dev/null +++ b/apps/frontend/src/theme/core/components/button-icon.tsx @@ -0,0 +1,44 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type IconButtonExtendColor = { black: true; white: true }; + +type IconButtonVariants = ComponentsVariants['MuiIconButton']; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette.common[colorKey], + }), + })) satisfies IconButtonVariants), +] satisfies IconButtonVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiIconButton: Components['MuiIconButton'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [...colorVariants], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const iconButton: Components = { + MuiIconButton, +}; diff --git a/apps/frontend/src/theme/core/components/button-toggle.tsx b/apps/frontend/src/theme/core/components/button-toggle.tsx new file mode 100644 index 0000000000..4df8545c7e --- /dev/null +++ b/apps/frontend/src/theme/core/components/button-toggle.tsx @@ -0,0 +1,123 @@ +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { toggleButtonClasses } from '@mui/material/ToggleButton'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +type ToggleButtonVariants = ComponentsVariants['MuiToggleButton']; +type ToggleButtonGroupVariants = ComponentsVariants['MuiToggleButtonGroup']; + +const SIZES = ['small', 'medium', 'large'] as const; +const DIMENSIONS: Record<(typeof SIZES)[number] | 'group', CSSObject> = { + small: { '--size': '40px', '--padding': '7px' }, + medium: { '--size': '48px', '--padding': '11px' }, + large: { '--size': '56px', '--padding': '15px' }, + group: { '--group-gap': '4px' }, +}; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => props.color === colorKey, + style: ({ theme }) => ({ + '&:hover': { + borderColor: varAlpha( + theme.vars.palette[colorKey].mainChannel, + theme.vars.opacity.outlined.border + ), + backgroundColor: varAlpha( + theme.vars.palette[colorKey].mainChannel, + theme.vars.palette.action.hoverOpacity + ), + }, + }), + })) satisfies ToggleButtonVariants), +] satisfies ToggleButtonVariants; + +const sizeVariants = [ + ...(SIZES.map((size) => ({ + props: (props) => props.size === size, + style: { ...DIMENSIONS[size] }, + })) satisfies ToggleButtonVariants), +] satisfies ToggleButtonVariants; + +const standaloneStateVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${toggleButtonClasses.selected}`]: { + borderColor: 'currentColor', + boxShadow: '0 0 0 0.75px currentColor', + }, + [`&.${toggleButtonClasses.disabled}`]: { + boxShadow: 'none', + color: theme.vars.palette.action.disabled, + borderColor: theme.vars.palette.action.disabledBackground, + [`&.${toggleButtonClasses.selected}`]: { + backgroundColor: theme.vars.palette.action.disabledBackground, + }, + }, + }), + }, +] satisfies ToggleButtonVariants; + +const groupedStateVariants = [ + { + props: {}, + style: { + [`&.${toggleButtonClasses.selected}`]: { boxShadow: 'none' }, + [`&.${toggleButtonClasses.disabled}`]: { border: 'none' }, + }, + }, +] satisfies ToggleButtonGroupVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiToggleButton: Components['MuiToggleButton'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + gap: 8, + minWidth: 'var(--size)', + minHeight: 'var(--size)', + padding: 'var(--padding)', + fontWeight: theme.typography.fontWeightSemiBold, + variants: [...colorVariants, ...sizeVariants, ...standaloneStateVariants], + }), + }, +}; + +const MuiToggleButtonGroup: Components['MuiToggleButtonGroup'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...DIMENSIONS.group, + gap: 'var(--group-gap)', + padding: 'var(--group-gap)', + border: `1px solid ${theme.vars.palette.shared.paperOutlined}`, + }), + grouped: () => ({ + border: 'none', + borderRadius: 'inherit', + padding: 'calc(var(--padding) - var(--group-gap))', + minWidth: 'calc(var(--size) - (var(--group-gap) * 2 + 2px))', + minHeight: 'calc(var(--size) - (var(--group-gap) * 2 + 2px))', + variants: [...groupedStateVariants], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const toggleButton: Components = { + MuiToggleButton, + MuiToggleButtonGroup, +}; diff --git a/apps/frontend/src/theme/core/components/button.tsx b/apps/frontend/src/theme/core/components/button.tsx new file mode 100644 index 0000000000..5cdce0a3cc --- /dev/null +++ b/apps/frontend/src/theme/core/components/button.tsx @@ -0,0 +1,215 @@ +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { buttonClasses } from '@mui/material/Button'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type ButtonExtendSize = { xLarge: true }; +export type ButtonExtendVariant = { soft: true }; +export type ButtonExtendColor = { black: true; white: true }; + +type ButtonVariants = ComponentsVariants['MuiButton']; + +const baseColors = ['inherit'] as const; +const allColors = [...baseColors, ...colorKeys.palette, ...colorKeys.common] as const; + +const DIMENSIONS: Record<'small' | 'medium' | 'large' | 'xLarge', CSSObject> = { + small: { '--padding-y': '4px', '--padding-x': '8px', minHeight: 30, lineHeight: 22 / 13 }, + medium: { '--padding-y': '6px', '--padding-x': '12px', minHeight: 36, lineHeight: 24 / 14 }, + large: { '--padding-y': '8px', '--padding-x': '16px', minHeight: 48, lineHeight: 26 / 15 }, + xLarge: { minHeight: 56 }, +}; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const containedVariants = [ + { + props: (props) => props.variant === 'contained' && props.color === 'inherit', + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'inherit', { + hover: { + boxShadow: theme.vars.customShadows.z8, + }, + }), + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.variant === 'contained' && props.color === colorKey, + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, colorKey, { + hover: { + boxShadow: theme.vars.customShadows.z8, + }, + }), + }), + })) satisfies ButtonVariants), + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => props.variant === 'contained' && props.color === colorKey, + style: ({ theme }) => ({ + '&:hover': { + boxShadow: theme.vars.customShadows[colorKey], + }, + }), + })) satisfies ButtonVariants), +] satisfies ButtonVariants; + +const outlinedVariants = [ + { + props: (props) => props.variant === 'outlined', + style: ({ theme }) => ({ + borderColor: varAlpha('currentColor', theme.vars.opacity.outlined.border), + '&:hover': { + borderColor: 'currentColor', + boxShadow: '0 0 0 0.75px currentColor', + backgroundColor: varAlpha('currentColor', theme.vars.palette.action.hoverOpacity), + }, + }), + }, + { + props: (props) => props.variant === 'outlined' && props.color === 'inherit', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + '&:hover': { + backgroundColor: theme.vars.palette.action.hover, + }, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.variant === 'outlined' && props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette.common[colorKey], + }), + })) satisfies ButtonVariants), +] satisfies ButtonVariants; + +const textVariants = [ + { + props: (props) => props.variant === 'text', + style: ({ theme }) => ({ + '&:hover': { + backgroundColor: varAlpha('currentColor', theme.vars.palette.action.hoverOpacity), + }, + }), + }, + { + props: (props) => props.variant === 'text' && props.color === 'inherit', + style: ({ theme }) => ({ + '&:hover': { + backgroundColor: theme.vars.palette.action.hover, + }, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.variant === 'text' && props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette.common[colorKey], + }), + })) satisfies ButtonVariants), +] satisfies ButtonVariants; + +const softVariants = [ + ...(allColors.map((colorKey) => ({ + props: (props) => props.variant === 'soft' && props.color === colorKey, + style: ({ theme }) => ({ + ...theme.mixins.softStyles(theme, colorKey, { hover: true }), + }), + })) satisfies ButtonVariants), +] satisfies ButtonVariants; + +const sizeVariants = [ + { + props: {}, + style: { padding: 'var(--padding-y) var(--padding-x)' }, + }, + { + props: (props) => props.size === 'small', + style: { ...DIMENSIONS.small }, + }, + { + props: (props) => props.size === 'medium', + style: { ...DIMENSIONS.medium }, + }, + { + props: (props) => props.size === 'large' || props.size === 'xLarge', + style: { ...DIMENSIONS.large }, + }, + { + props: (props) => props.size === 'xLarge', + style: ({ theme }) => ({ ...DIMENSIONS.xLarge, fontSize: theme.typography.pxToRem(15) }), + }, + { + props: (props) => props.variant === 'outlined', + style: { + paddingTop: 'calc(var(--padding-y) - 4px)', + paddingBottom: 'calc(var(--padding-y) - 4px)', + }, + }, + { + props: (props) => props.variant === 'text', + style: { + paddingLeft: 'calc(var(--padding-x) - 4px)', + paddingRight: 'calc(var(--padding-x) - 4px)', + }, + }, +] satisfies ButtonVariants; + +const disabledVariants = [ + { + props: (props) => props.variant === 'soft', + style: ({ theme }) => ({ + [`&.${buttonClasses.disabled}`]: { + backgroundColor: theme.vars.palette.action.disabledBackground, + }, + }), + }, +] satisfies ButtonVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiButtonBase: Components['MuiButtonBase'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + fontFamily: theme.typography.fontFamily, + }), + }, +}; + +const MuiButton: Components['MuiButton'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'inherit', + disableElevation: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [ + ...containedVariants, + ...outlinedVariants, + ...textVariants, + ...softVariants, + ...sizeVariants, + ...disabledVariants, + ], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const button: Components = { + MuiButton, + MuiButtonBase, +}; diff --git a/apps/frontend/src/theme/core/components/card.tsx b/apps/frontend/src/theme/core/components/card.tsx new file mode 100644 index 0000000000..3756e326a2 --- /dev/null +++ b/apps/frontend/src/theme/core/components/card.tsx @@ -0,0 +1,47 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiCard: Components['MuiCard'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + position: 'relative', + boxShadow: `var(--card-shadow, ${theme.vars.customShadows.card})`, + borderRadius: `var(--card-radius, ${Number(theme.shape.borderRadius) * 2}px)`, + zIndex: 0, // Fix Safari overflow: hidden with border radius + }), + }, +}; + +const MuiCardHeader: Components['MuiCardHeader'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + titleTypographyProps: { variant: 'h6' }, + subheaderTypographyProps: { variant: 'body2', marginTop: '4px' }, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(3, 3, 0), + }), + }, +}; + +const MuiCardContent: Components['MuiCardContent'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(3), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const card: Components = { + MuiCard, + MuiCardHeader, + MuiCardContent, +}; diff --git a/apps/frontend/src/theme/core/components/checkbox.tsx b/apps/frontend/src/theme/core/components/checkbox.tsx new file mode 100644 index 0000000000..be36cce34e --- /dev/null +++ b/apps/frontend/src/theme/core/components/checkbox.tsx @@ -0,0 +1,64 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components } from '@mui/material/styles'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { checkboxClasses } from '@mui/material/Checkbox'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const Icon = (props: SvgIconProps) => ( + + + +); + +const CheckedIcon = (props: SvgIconProps) => ( + + + +); + +const IndeterminateIcon = (props: SvgIconProps) => ( + + + +); + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiCheckbox: Components['MuiCheckbox'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + size: 'small', + icon: , + checkedIcon: , + indeterminateIcon: , + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(1), + variants: [ + { + props: (props) => props.color === 'default', + style: { + [`&.${checkboxClasses.checked}`]: { + color: theme.vars.palette.text.primary, + }, + }, + }, + ], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const checkbox: Components = { + MuiCheckbox, +}; diff --git a/apps/frontend/src/theme/core/components/chip.tsx b/apps/frontend/src/theme/core/components/chip.tsx new file mode 100644 index 0000000000..b488f6f0d8 --- /dev/null +++ b/apps/frontend/src/theme/core/components/chip.tsx @@ -0,0 +1,200 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { chipClasses } from '@mui/material/Chip'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type ChipExtendVariant = { soft: true }; +export type ChipExtendColor = { black: true; white: true }; + +type ChipVariants = ComponentsVariants['MuiChip']; + +const baseColors = ['default'] as const; +const allColors = [...baseColors, ...colorKeys.palette, ...colorKeys.common] as const; + +const DIMENSIONS: Record<'small' | 'medium', CSSObject> = { + small: { borderRadius: '8px' }, + medium: { borderRadius: '10px' }, +}; + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const DeleteIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/close-circle-bold/ + + + +); + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const filledVariants = [ + { + props: (props) => props.variant === 'filled' && props.color === 'default', + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, 'inherit'), + [`&.${chipClasses.clickable}`]: { + ...theme.mixins.filledStyles(theme, 'inherit', { hover: true }), + }, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.variant === 'filled' && props.color === colorKey, + style: ({ theme }) => ({ + ...theme.mixins.filledStyles(theme, colorKey), + [`&.${chipClasses.clickable}`]: { + ...theme.mixins.filledStyles(theme, colorKey, { hover: true }), + }, + }), + })) satisfies ChipVariants), +] satisfies ChipVariants; + +const outlinedVariants = [ + { + props: (props) => props.variant === 'outlined', + style: { + borderColor: 'currentColor', + }, + }, + { + props: (props) => props.variant === 'outlined' && props.color === 'default', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + }), + }, + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.variant === 'outlined' && props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette.common[colorKey], + }), + })) satisfies ChipVariants), +] satisfies ChipVariants; + +const softVariants = [ + ...(allColors.map((colorKey) => ({ + props: (props) => props.variant === 'soft' && props.color === colorKey, + style: ({ theme }) => { + const currentColor = colorKey === 'default' ? 'inherit' : colorKey; + + return { + ...theme.mixins.softStyles(theme, currentColor), + [`&.${chipClasses.clickable}`]: { + ...theme.mixins.softStyles(theme, currentColor, { hover: true }), + }, + }; + }, + })) satisfies ChipVariants), +] satisfies ChipVariants; + +const avatarVariants = [ + ...(colorKeys.common.map((colorKey) => ({ + props: (props) => props.color === colorKey, + style: { + color: 'inherit', + backgroundColor: 'color-mix(in srgb, currentColor 24%, transparent)', + }, + })) satisfies ChipVariants), + ...(colorKeys.palette.map((colorKey) => ({ + props: (props) => props.color === colorKey, + style: ({ theme }) => ({ + color: theme.vars.palette[colorKey].lighter, + backgroundColor: theme.vars.palette[colorKey].dark, + }), + })) satisfies ChipVariants), +] satisfies ChipVariants; + +const sizeVariants = [ + { + props: (props) => props.size === 'small', + style: { ...DIMENSIONS.small }, + }, + { + props: (props) => props.size === 'medium', + style: { ...DIMENSIONS.medium }, + }, +] satisfies ChipVariants; + +const disabledVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${chipClasses.disabled}`]: { + opacity: 1, + color: theme.vars.palette.action.disabled, + [`&:not(.${chipClasses.outlined})`]: { + backgroundColor: theme.vars.palette.action.disabledBackground, + }, + [`&.${chipClasses.outlined}`]: { + borderColor: theme.vars.palette.action.disabledBackground, + }, + [`& .${chipClasses.avatar}`]: { + color: theme.vars.palette.action.disabled, + backgroundColor: theme.vars.palette.action.disabledBackground, + '& img': { opacity: theme.vars.palette.action.disabledOpacity }, + }, + }, + }), + }, +] satisfies ChipVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiChip: Components['MuiChip'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + deleteIcon: , + variant: 'soft', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [ + ...filledVariants, + ...outlinedVariants, + ...softVariants, + ...sizeVariants, + ...disabledVariants, + ], + }, + label: ({ theme }) => ({ + fontWeight: theme.typography.fontWeightMedium, + }), + avatar: { + variants: [...avatarVariants], + }, + icon: { + color: 'currentColor', + }, + deleteIcon: { + opacity: 0.48, + color: 'currentColor', + '&:hover': { + opacity: 0.8, + color: 'currentColor', + }, + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const chip: Components = { + MuiChip, +}; diff --git a/apps/frontend/src/theme/core/components/dialog.tsx b/apps/frontend/src/theme/core/components/dialog.tsx new file mode 100644 index 0000000000..0961632053 --- /dev/null +++ b/apps/frontend/src/theme/core/components/dialog.tsx @@ -0,0 +1,70 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiDialog: Components['MuiDialog'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + paper: { + variants: [ + { + props: (props) => !props.fullScreen, + style: ({ theme }) => ({ + margin: theme.spacing(2), + boxShadow: theme.vars.customShadows.dialog, + borderRadius: Number(theme.shape.borderRadius) * 2, + }), + }, + ], + }, + }, +}; + +const MuiDialogTitle: Components['MuiDialogTitle'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(3), + }), + }, +}; + +const MuiDialogContent: Components['MuiDialogContent'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(0, 3), + }), + dividers: ({ theme }) => ({ + borderTop: 0, + borderBottomStyle: 'dashed', + paddingBottom: theme.spacing(3), + }), + }, +}; + +const MuiDialogActions: Components['MuiDialogActions'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + disableSpacing: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(3), + '& > :not(:first-of-type)': { + marginLeft: theme.spacing(1.5), + }, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const dialog: Components = { + MuiDialog, + MuiDialogTitle, + MuiDialogContent, + MuiDialogActions, +}; diff --git a/apps/frontend/src/theme/core/components/drawer.tsx b/apps/frontend/src/theme/core/components/drawer.tsx new file mode 100644 index 0000000000..47acfa0642 --- /dev/null +++ b/apps/frontend/src/theme/core/components/drawer.tsx @@ -0,0 +1,42 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +const MuiDrawer: Components['MuiDrawer'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + paper: { + variants: [ + { + props: (props) => props.variant === 'temporary' && props.anchor === 'left', + style: ({ theme }) => ({ + ...theme.mixins.paperStyles(theme), + boxShadow: `40px 40px 80px -8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`, + ...theme.applyStyles('dark', { + boxShadow: `40px 40px 80px -8px ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`, + }), + }), + }, + { + props: (props) => props.variant === 'temporary' && props.anchor === 'right', + style: ({ theme }) => ({ + ...theme.mixins.paperStyles(theme), + boxShadow: `-40px 40px 80px -8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`, + ...theme.applyStyles('dark', { + boxShadow: `-40px 40px 80px -8px ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`, + }), + }), + }, + ], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const drawer: Components = { + MuiDrawer, +}; diff --git a/apps/frontend/src/theme/core/components/form.tsx b/apps/frontend/src/theme/core/components/form.tsx new file mode 100644 index 0000000000..0ad80b84e8 --- /dev/null +++ b/apps/frontend/src/theme/core/components/form.tsx @@ -0,0 +1,111 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { formLabelClasses } from '@mui/material/FormLabel'; +import { inputLabelClasses } from '@mui/material/InputLabel'; + +import { getInputTypography } from './text-field'; + +// ---------------------------------------------------------------------- + +const MuiFormControl: Components['MuiFormControl'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + variant: 'outlined', + }, +}; + +/** + * Applies label styles to TextField and Select. + */ +const MuiInputLabel: Components['MuiInputLabel'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + variants: [ + { + props: (props) => !props.shrink, + style: { + ...getInputTypography(theme, ['fontSize', 'lineHeight']), + color: theme.vars.palette.text.disabled, + }, + }, + { + props: (props) => !!props.shrink, + style: { + fontWeight: theme.typography.fontWeightSemiBold, + [`&.${inputLabelClasses.focused}:not(.${inputLabelClasses.error})`]: { + color: 'inherit', + }, + }, + }, + { + props: (props) => !!props.shrink && props.variant === 'filled' && props.size === 'medium', + style: { + transform: 'translate(12px, 6px) scale(0.75)', + }, + }, + ], + }), + }, +}; + +/** + * Applies label styles to Checkbox, RadioGroup, Switch. + */ +const MuiFormLabel: Components['MuiFormLabel'] = { + // // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [`&.${formLabelClasses.disabled}`]: { + color: theme.vars.palette.action.disabled, + }, + variants: [ + { + props: (props) => !props.error, + style: { + [`&.${formLabelClasses.focused}`]: { + color: theme.vars.palette.text.secondary, + }, + }, + }, + ], + }), + }, +}; + +const MuiFormControlLabel: Components['MuiFormControlLabel'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + label: ({ theme }) => ({ + ...theme.typography.body2, + }), + }, +}; + +const MuiFormHelperText: Components['MuiFormHelperText'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + component: 'div', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + margin: theme.spacing(0.75, 1.5, 0, 1.5), + '& > svg': { width: 16, height: 16 }, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const form: Components = { + MuiFormLabel, + MuiInputLabel, + MuiFormControl, + MuiFormHelperText, + MuiFormControlLabel, +}; diff --git a/apps/frontend/src/theme/core/components/index.ts b/apps/frontend/src/theme/core/components/index.ts new file mode 100644 index 0000000000..5c24b3654e --- /dev/null +++ b/apps/frontend/src/theme/core/components/index.ts @@ -0,0 +1,90 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { list } from './list'; +import { card } from './card'; +import { menu } from './menu'; +import { chip } from './chip'; +import { link } from './link'; +import { form } from './form'; +import { tabs } from './tabs'; +import { table } from './table'; +import { alert } from './alert'; +import { stack } from './stack'; +import { badge } from './badge'; +import { radio } from './radio'; +import { paper } from './paper'; +import { appBar } from './appbar'; +import { dialog } from './dialog'; +import { avatar } from './avatar'; +import { drawer } from './drawer'; +import { select } from './select'; +import { rating } from './rating'; +import { slider } from './slider'; +import { button } from './button'; +import { fab } from './button-fab'; +import { tooltip } from './tooltip'; +import { popover } from './popover'; +import { stepper } from './stepper'; +import { switches } from './switch'; +import { svgIcon } from './svg-icon'; +import { skeleton } from './skeleton'; +import { backdrop } from './backdrop'; +import { progress } from './progress'; +import { checkbox } from './checkbox'; +import { accordion } from './accordion'; +import { textField } from './text-field'; +import { pagination } from './pagination'; +import { iconButton } from './button-icon'; +import { breadcrumbs } from './breadcrumbs'; +import { buttonGroup } from './button-group'; +import { autocomplete } from './autocomplete'; +import { toggleButton } from './button-toggle'; +import { datePicker } from './mui-x-date-picker'; + +// ---------------------------------------------------------------------- + +export const components: Components = { + ...card, + ...link, + ...tabs, + ...chip, + ...menu, + ...list, + ...stack, + ...paper, + ...table, + ...alert, + ...badge, + ...dialog, + ...appBar, + ...avatar, + ...drawer, + ...stepper, + ...tooltip, + ...popover, + ...svgIcon, + ...skeleton, + ...backdrop, + ...progress, + ...accordion, + ...pagination, + ...breadcrumbs, + // ➤➤ Forms ➤➤ + ...form, + ...radio, + ...select, + ...slider, + ...rating, + ...switches, + ...checkbox, + ...textField, + ...autocomplete, + // ➤➤ Buttons ➤➤ + ...fab, + ...button, + ...iconButton, + ...buttonGroup, + ...toggleButton, + // ➤➤ MUI X ➤➤ + ...datePicker, +}; diff --git a/apps/frontend/src/theme/core/components/link.tsx b/apps/frontend/src/theme/core/components/link.tsx new file mode 100644 index 0000000000..66728da521 --- /dev/null +++ b/apps/frontend/src/theme/core/components/link.tsx @@ -0,0 +1,17 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiLink: Components['MuiLink'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + underline: 'hover', + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const link: Components = { + MuiLink, +}; diff --git a/apps/frontend/src/theme/core/components/list.tsx b/apps/frontend/src/theme/core/components/list.tsx new file mode 100644 index 0000000000..ebb30908ff --- /dev/null +++ b/apps/frontend/src/theme/core/components/list.tsx @@ -0,0 +1,48 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiListItemIcon: Components['MuiListItemIcon'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + color: 'inherit', + minWidth: 'auto', + marginRight: theme.spacing(2), + }), + }, +}; + +const MuiListItemAvatar: Components['MuiListItemAvatar'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + minWidth: 'auto', + marginRight: theme.spacing(2), + }), + }, +}; + +const MuiListItemText: Components['MuiListItemText'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + slotProps: { + primary: { typography: 'subtitle2' }, + secondary: { component: 'span' }, + }, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { margin: 0 }, + multiline: { margin: 0 }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const list: Components = { + MuiListItemIcon, + MuiListItemText, + MuiListItemAvatar, +}; diff --git a/apps/frontend/src/theme/core/components/menu.tsx b/apps/frontend/src/theme/core/components/menu.tsx new file mode 100644 index 0000000000..a423b694a8 --- /dev/null +++ b/apps/frontend/src/theme/core/components/menu.tsx @@ -0,0 +1,19 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiMenuItem: Components['MuiMenuItem'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...theme.mixins.menuItemStyles(theme), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const menu: Components = { + MuiMenuItem, +}; diff --git a/apps/frontend/src/theme/core/components/mui-x-date-picker.tsx b/apps/frontend/src/theme/core/components/mui-x-date-picker.tsx new file mode 100644 index 0000000000..9b0c1e82b6 --- /dev/null +++ b/apps/frontend/src/theme/core/components/mui-x-date-picker.tsx @@ -0,0 +1,300 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components } from '@mui/material/styles'; +import type { TextFieldProps } from '@mui/material/TextField'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { buttonClasses } from '@mui/material/Button'; +import { inputLabelClasses } from '@mui/material/InputLabel'; +import { pickersSectionListClasses } from '@mui/x-date-pickers/PickersSectionList'; +import { + pickersInputBaseClasses, + pickersFilledInputClasses, + pickersOutlinedInputClasses, +} from '@mui/x-date-pickers/PickersTextField'; + +import { + inputStyles, + inputBaseStyles, + filledInputStyles, + inputBaseVariants, + outlinedInputStyles, + filledInputVariants, + outlinedInputVariants, +} from './text-field'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const SwitchViewIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/eva/chevron-down-fill/ + + + +); + +const LeftArrowIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/eva/arrow-ios-back-fill/ + + + +); + +const RightArrowIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/eva/arrow-ios-forward-fill/ + + + +); + +const CalendarIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/calendar-mark-bold-duotone/ + + + + + +); + +const ClockIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/solar/clock-circle-outline/ + + + +); + +// ---------------------------------------------------------------------- + +const baseSlots = { + switchViewIcon: SwitchViewIcon, + leftArrowIcon: LeftArrowIcon, + rightArrowIcon: RightArrowIcon, +}; + +const defaultProps = { + dateSlots: { ...baseSlots, openPickerIcon: CalendarIcon }, + timeSlots: { ...baseSlots, openPickerIcon: ClockIcon }, + tabs: { dateIcon: , timeIcon: }, + baseField: { + slotProps: { + textField: { fullWidth: true } satisfies Partial, + }, + }, +}; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiPickersLayout: Components['MuiPickersLayout'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + actionBar: ({ theme }) => ({ + padding: theme.spacing(2), + '& > :not(:first-of-type)': { + marginLeft: theme.spacing(1), + }, + [`& .${buttonClasses.root}`]: { + '&:last-of-type': { + ...theme.mixins.filledStyles(theme, 'inherit', { + hover: { + boxShadow: theme.vars.customShadows.z8, + }, + }), + }, + }, + }), + }, +}; + +const MuiPickerPopper: Components['MuiPickerPopper'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + paper: ({ theme }) => ({ + boxShadow: theme.vars.customShadows.dropdown, + borderRadius: Number(theme.shape.borderRadius) * 1.5, + }), + }, +}; + +const MuiDateTimePickerTabs: Components['MuiDateTimePickerTabs'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { ...defaultProps.tabs }, +}; + +const MuiClock: Components['MuiClock'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + clock: ({ theme }) => ({ + backgroundColor: theme.vars.palette.background.neutral, + }), + }, +}; + +const inputComponents: Components = { + MuiPickersTextField: { + defaultProps: { + variant: 'outlined', + }, + styleOverrides: { + root: { + variants: [ + { + props: (props) => !props.isFieldFocused && !props.isFieldValueEmpty, + style: { + [`& .${inputLabelClasses.root}[data-shrink="false"] + .${pickersInputBaseClasses.root} > .${pickersSectionListClasses.root}`]: + { + opacity: 0, + }, + }, + }, + ], + }, + }, + }, + MuiPickersInputBase: { + styleOverrides: { + root: ({ theme }) => ({ + ...inputBaseStyles.root('picker', theme, { + input: pickersSectionListClasses.root, + disabled: pickersInputBaseClasses.disabled, + }), + }), + sectionsContainer: ({ theme }) => ({ + ...inputBaseStyles.input('picker', theme), + variants: [ + ...inputBaseVariants.input, + { + props: (props) => !props.isFieldFocused && !!props.isFieldValueEmpty, + style: { + opacity: 1, + color: theme.vars.palette.text.disabled, + }, + }, + ], + }), + }, + }, + MuiPickersInput: { + styleOverrides: { + root: ({ theme }) => inputStyles.root(theme), + }, + }, + MuiPickersOutlinedInput: { + styleOverrides: { + root: ({ theme }) => outlinedInputStyles.root(theme, pickersOutlinedInputClasses), + sectionsContainer: { variants: [...outlinedInputVariants.input] }, + notchedOutline: ({ theme }) => outlinedInputStyles.notchedOutline(theme), + }, + }, + MuiPickersFilledInput: { + defaultProps: { + disableUnderline: true, + }, + styleOverrides: { + root: ({ theme }) => filledInputStyles.root(theme, pickersFilledInputClasses), + sectionsContainer: { variants: [...filledInputVariants.input] }, + }, + }, +}; + +const toolbarComponents: Components = { + MuiPickersToolbar: { + styleOverrides: { + content: { marginTop: 8 }, + }, + }, + MuiPickersToolbarButton: { + styleOverrides: { + root: { minWidth: 36 }, + }, + }, + MuiTimePickerToolbar: { + styleOverrides: { + separator: { marginLeft: 2, marginRight: 2 }, + ampmLandscape: { gap: 16, justifyContent: 'flex-start' }, + ampmLabel: ({ theme }) => ({ ...theme.typography.subtitle1 }), + }, + }, + MuiDateTimePickerToolbar: { + styleOverrides: { + separator: { marginLeft: 2, marginRight: 2 }, + ampmLandscape: { gap: 16, justifyContent: 'flex-start' }, + ampmLabel: ({ theme }) => ({ ...theme.typography.subtitle1 }), + timeDigitsContainer: { alignItems: 'center' }, + }, + }, +}; + +/** + * ➤ Date picker + * - https://mui.com/x/react-date-pickers/date-picker/ + */ +const datePickerComponents: Components = { + MuiDateField: { defaultProps: { ...defaultProps.baseField } }, + MuiDatePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiDesktopDatePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiMobileDatePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiStaticDatePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, +}; + +/** + * ➤ Time picker + * - https://mui.com/x/react-date-pickers/time-picker/ + */ +const timePickerComponents: Components = { + MuiTimeField: { defaultProps: { ...defaultProps.baseField } }, + MuiTimePicker: { defaultProps: { slots: { ...defaultProps.timeSlots } } }, + MuiDesktopTimePicker: { defaultProps: { slots: { ...defaultProps.timeSlots } } }, + MuiMobileTimePicker: { defaultProps: { slots: { ...defaultProps.timeSlots } } }, + MuiStaticTimePicker: { defaultProps: { slots: { ...defaultProps.timeSlots } } }, +}; + +/** + * ➤ Date & Time picker + * - https://mui.com/x/react-date-pickers/date-time-picker/ + */ +const dateTimePickerComponents: Components = { + MuiDateTimeField: { defaultProps: { ...defaultProps.baseField } }, + MuiDateTimePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiDesktopDateTimePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiMobileDateTimePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, + MuiStaticDateTimePicker: { defaultProps: { slots: { ...defaultProps.dateSlots } } }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const datePicker: Components = { + ...toolbarComponents, + MuiClock, + MuiPickerPopper, + MuiPickersLayout, + MuiDateTimePickerTabs, + /********/ + ...inputComponents, + ...datePickerComponents, + ...timePickerComponents, + ...dateTimePickerComponents, +}; diff --git a/apps/frontend/src/theme/core/components/pagination.tsx b/apps/frontend/src/theme/core/components/pagination.tsx new file mode 100644 index 0000000000..d93c384401 --- /dev/null +++ b/apps/frontend/src/theme/core/components/pagination.tsx @@ -0,0 +1,120 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { paginationItemClasses } from '@mui/material/PaginationItem'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type PaginationExtendVariant = { soft: true }; +export type PaginationExtendColor = { + info: true; + success: true; + warning: true; + error: true; +}; + +type PaginationItemVariants = ComponentsVariants['MuiPaginationItem']; + +const baseColors = ['standard'] as const; +const allColors = [...baseColors, ...colorKeys.palette] as const; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const textVariants = [ + { + props: (props) => props.variant === 'text' && props.color === 'standard', + style: ({ theme }) => ({ + [`&.${paginationItemClasses.selected}`]: { + ...theme.mixins.filledStyles(theme, 'inherit', { hover: true }), + }, + }), + }, +] satisfies PaginationItemVariants; + +const outlinedVariants = [ + { + props: (props) => props.variant === 'outlined', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.buttonOutlined, + [`&.${paginationItemClasses.selected}`]: { + borderColor: 'currentColor', + backgroundColor: varAlpha('currentColor', theme.vars.palette.action.selectedOpacity), + '&:hover': { + backgroundColor: varAlpha( + 'currentColor', + `calc(${theme.vars.palette.action.selectedOpacity} * 2)` + ), + }, + }, + }), + }, + { + props: (props) => props.variant === 'outlined' && props.color === 'standard', + style: ({ theme }) => ({ + [`&.${paginationItemClasses.selected}`]: { + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + '&:hover': { + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + }, + }, + }), + }, +] satisfies PaginationItemVariants; + +const softVariants = [ + ...(allColors.map((colorKey) => ({ + props: (props) => props.variant === 'soft' && props.color === colorKey, + style: ({ theme }) => { + const currentColor = colorKey === 'standard' ? 'inherit' : colorKey; + + return { + [`&.${paginationItemClasses.selected}`]: { + ...theme.mixins.softStyles(theme, currentColor, { hover: true }), + }, + }; + }, + })) satisfies PaginationItemVariants), +] satisfies PaginationItemVariants; + +const disabledVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${paginationItemClasses.disabled}`]: { + [`&.${paginationItemClasses.selected}`]: { + backgroundColor: theme.vars.palette.action.disabledBackground, + }, + }, + }), + }, +] satisfies PaginationItemVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiPaginationItem: Components['MuiPaginationItem'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [`&.${paginationItemClasses.selected}`]: { + fontWeight: theme.typography.fontWeightSemiBold, + }, + variants: [...textVariants, ...outlinedVariants, ...softVariants, ...disabledVariants], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const pagination: Components = { + MuiPaginationItem, +}; diff --git a/apps/frontend/src/theme/core/components/paper.tsx b/apps/frontend/src/theme/core/components/paper.tsx new file mode 100644 index 0000000000..36b28b69da --- /dev/null +++ b/apps/frontend/src/theme/core/components/paper.tsx @@ -0,0 +1,31 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiPaper: Components['MuiPaper'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + elevation: 0, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + backgroundImage: 'none', + variants: [ + { + props: (props) => props.variant === 'outlined', + style: ({ theme }) => ({ + borderColor: theme.vars.palette.shared.paperOutlined, + }), + }, + ], + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const paper: Components = { + MuiPaper, +}; diff --git a/apps/frontend/src/theme/core/components/popover.tsx b/apps/frontend/src/theme/core/components/popover.tsx new file mode 100644 index 0000000000..c24ef88885 --- /dev/null +++ b/apps/frontend/src/theme/core/components/popover.tsx @@ -0,0 +1,25 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { listClasses } from '@mui/material/List'; + +// ---------------------------------------------------------------------- + +const MuiPopover: Components['MuiPopover'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + paper: ({ theme }) => ({ + ...theme.mixins.paperStyles(theme, { dropdown: true }), + [`& .${listClasses.root}`]: { + paddingTop: 0, + paddingBottom: 0, + }, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const popover: Components = { + MuiPopover, +}; diff --git a/apps/frontend/src/theme/core/components/progress.tsx b/apps/frontend/src/theme/core/components/progress.tsx new file mode 100644 index 0000000000..eb747ee41c --- /dev/null +++ b/apps/frontend/src/theme/core/components/progress.tsx @@ -0,0 +1,97 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { linearProgressClasses } from '@mui/material/LinearProgress'; + +import { colorKeys } from '../palette'; + +// ---------------------------------------------------------------------- + +type LinearProgressVariants = ComponentsVariants['MuiLinearProgress']; + +const baseColors = ['inherit'] as const; +const allColors = [...baseColors, ...colorKeys.palette] as const; + +const LINEAR_OPACITY = { track: 0.24, dashed: 0.48 } as const; + +function getColorStyle(theme: Theme, colorKey: (typeof allColors)[number]) { + if (colorKey === 'inherit') { + return { + '&::before': { opacity: LINEAR_OPACITY.track }, + [`& .${linearProgressClasses.bar2}`]: { opacity: 1 }, + }; + } + + return { + backgroundColor: varAlpha(theme.vars.palette[colorKey].mainChannel, LINEAR_OPACITY.track), + }; +} + +function getBufferStyle(theme: Theme, colorKey: (typeof allColors)[number]) { + const isInherit = colorKey === 'inherit'; + + const gradientColor = isInherit ? 'currentColor' : theme.vars.palette[colorKey].mainChannel; + const backgroundColor = isInherit + ? 'currentColor' + : varAlpha(theme.vars.palette[colorKey].mainChannel, LINEAR_OPACITY.track); + + return { + [`& .${linearProgressClasses.bar2}`]: { + backgroundColor, + ...(isInherit && { opacity: LINEAR_OPACITY.track }), + }, + [`& .${linearProgressClasses.dashed}`]: { + backgroundImage: `radial-gradient(${varAlpha(gradientColor, LINEAR_OPACITY.dashed)} 0%, ${varAlpha(gradientColor, LINEAR_OPACITY.dashed)} 16%, transparent 42%)`, + }, + }; +} + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + ...(allColors.map((colorKey) => ({ + props: (props) => props.color === colorKey && props.variant !== 'buffer', + style: ({ theme }) => getColorStyle(theme, colorKey), + })) satisfies LinearProgressVariants), + ...(allColors.map((colorKey) => ({ + props: (props) => props.color === colorKey && props.variant === 'buffer', + style: ({ theme }) => getBufferStyle(theme, colorKey), + })) satisfies LinearProgressVariants), +] satisfies LinearProgressVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiCircularProgress: Components['MuiCircularProgress'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'inherit', + }, +}; + +const MuiLinearProgress: Components['MuiLinearProgress'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + color: 'inherit', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + borderRadius: 16, + variants: [...colorVariants], + }, + bar: { + borderRadius: 'inherit', + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const progress: Components = { + MuiLinearProgress, + MuiCircularProgress, +}; diff --git a/apps/frontend/src/theme/core/components/radio.tsx b/apps/frontend/src/theme/core/components/radio.tsx new file mode 100644 index 0000000000..e5011d6f0f --- /dev/null +++ b/apps/frontend/src/theme/core/components/radio.tsx @@ -0,0 +1,65 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components } from '@mui/material/styles'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { radioClasses } from '@mui/material/Radio'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const Icon = (props: SvgIconProps) => ( + + + +); + +const CheckedIcon = (props: SvgIconProps) => ( + + + +); + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiRadio: Components['MuiRadio'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + size: 'small', + icon: , + checkedIcon: , + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(1), + variants: [ + { + props: (props) => props.color === 'default', + style: { + [`&.${radioClasses.checked}`]: { + color: theme.vars.palette.text.primary, + }, + }, + }, + ], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const radio: Components = { + MuiRadio, +}; diff --git a/apps/frontend/src/theme/core/components/rating.tsx b/apps/frontend/src/theme/core/components/rating.tsx new file mode 100644 index 0000000000..b6a039ada3 --- /dev/null +++ b/apps/frontend/src/theme/core/components/rating.tsx @@ -0,0 +1,74 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type RatingExtendSize = { xxSmall: true; xSmall: true }; + +type RatingVariants = ComponentsVariants['MuiRating']; + +const SIZES = ['xxSmall', 'xSmall', 'small', 'medium', 'large'] as const; +const DIMENSIONS: Record<(typeof SIZES)[number], number> = { + xxSmall: 12, + xSmall: 16, + small: 20, + medium: 24, + large: 28, +}; + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const Icon = (props: SvgIconProps) => ( + + + +); + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const sizeVariants = [ + ...(SIZES.map((size) => ({ + props: (props) => props.size === size, + style: { fontSize: DIMENSIONS[size] }, + })) satisfies RatingVariants), +] satisfies RatingVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiRating: Components['MuiRating'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + icon: , + emptyIcon: , + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + icon: { + variants: [...sizeVariants], + }, + iconEmpty: ({ theme }) => ({ + color: varAlpha( + theme.vars.palette.grey['500Channel'], + theme.vars.palette.action.disabledOpacity + ), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const rating: Components = { + MuiRating, +}; diff --git a/apps/frontend/src/theme/core/components/select.tsx b/apps/frontend/src/theme/core/components/select.tsx new file mode 100644 index 0000000000..247fda2cc3 --- /dev/null +++ b/apps/frontend/src/theme/core/components/select.tsx @@ -0,0 +1,63 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, CSSObject, Components } from '@mui/material/styles'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * ♉️ Custom icons + * **********************************************************************/ +const ArrowDownIcon = (props: SvgIconProps) => ( + // https://icon-sets.iconify.design/eva/arrow-ios-downward-fill/ + + + +); + +const arrowStyles: CSSObject = { + right: 10, + width: 18, + height: 18, + top: 'calc(50% - 9px)', +}; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiSelect: Components['MuiSelect'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + IconComponent: ArrowDownIcon, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + icon: { + ...arrowStyles, + }, + }, +}; + +const MuiNativeSelect: Components['MuiNativeSelect'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + IconComponent: ArrowDownIcon, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + icon: { + ...arrowStyles, + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const select: Components = { + MuiSelect, + MuiNativeSelect, +}; diff --git a/apps/frontend/src/theme/core/components/skeleton.tsx b/apps/frontend/src/theme/core/components/skeleton.tsx new file mode 100644 index 0000000000..3be504634c --- /dev/null +++ b/apps/frontend/src/theme/core/components/skeleton.tsx @@ -0,0 +1,29 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +const MuiSkeleton: Components['MuiSkeleton'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + animation: 'wave', + variant: 'rounded', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: varAlpha(theme.vars.palette.grey['400Channel'], 0.12), + }), + rounded: ({ theme }) => ({ + borderRadius: Number(theme.shape.borderRadius) * 2, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const skeleton: Components = { + MuiSkeleton, +}; diff --git a/apps/frontend/src/theme/core/components/slider.tsx b/apps/frontend/src/theme/core/components/slider.tsx new file mode 100644 index 0000000000..305b54e86b --- /dev/null +++ b/apps/frontend/src/theme/core/components/slider.tsx @@ -0,0 +1,163 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { sliderClasses } from '@mui/material/Slider'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type SliderExtendColor = { inherit: true }; + +type SliderVariants = ComponentsVariants['MuiSlider']; + +const SIZES = ['small', 'medium'] as const; +const ORIENTATIONS = ['horizontal', 'vertical'] as const; +const DIMENSIONS: Record<(typeof SIZES)[number], { rail: number; thumb: number; mark: number }> = { + small: { rail: 6, thumb: 16, mark: 4 }, + medium: { rail: 10, thumb: 20, mark: 6 }, +}; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const thumbVariants = [ + ...(SIZES.map((size) => ({ + props: (props) => props.size === size, + style: { + width: DIMENSIONS[size].thumb, + height: DIMENSIONS[size].thumb, + }, + })) satisfies SliderVariants), +] satisfies SliderVariants; + +const railVariants = [ + ...(ORIENTATIONS.flatMap((orientation) => + SIZES.map((size) => ({ + props: (props) => props.orientation === orientation && props.size === size, + style: + orientation === 'horizontal' + ? { height: DIMENSIONS[size].rail } + : { width: DIMENSIONS[size].rail }, + })) + ) satisfies SliderVariants), +] satisfies SliderVariants; + +const trackVariants = [ + ...(ORIENTATIONS.flatMap((orientation) => + SIZES.map((size) => ({ + props: (props) => props.orientation === orientation && props.size === size, + style: + orientation === 'horizontal' + ? { height: DIMENSIONS[size].rail } + : { width: DIMENSIONS[size].rail }, + })) + ) satisfies SliderVariants), +] satisfies SliderVariants; + +const markVariants = [ + ...(ORIENTATIONS.flatMap((orientation) => + SIZES.map((size) => ({ + props: (props) => props.orientation === orientation && props.size === size, + style: + orientation === 'horizontal' + ? { width: 1, height: DIMENSIONS[size].mark } + : { height: 1, width: DIMENSIONS[size].mark }, + })) + ) satisfies SliderVariants), +] satisfies SliderVariants; + +const markActiveVariants = [ + { + props: (props) => props.color === 'inherit', + style: ({ theme }) => ({ + ...theme.applyStyles('dark', { + backgroundColor: varAlpha(theme.vars.palette.grey['800Channel'], 0.48), + }), + }), + }, +] satisfies SliderVariants; + +const disabledVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${sliderClasses.disabled}`]: { + color: theme.vars.palette.action.disabled, + }, + }), + }, +] satisfies SliderVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiSlider: Components['MuiSlider'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + size: 'small', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [...disabledVariants], + }, + thumb: ({ theme }) => ({ + boxShadow: theme.vars.customShadows.z1, + color: theme.vars.palette.common.white, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + '&::before': { + opacity: 0.4, + boxShadow: 'none', + width: 'calc(100% - 4px)', + height: 'calc(100% - 4px)', + backgroundImage: `linear-gradient(180deg, ${theme.vars.palette.grey[500]}, transparent)`, + ...theme.applyStyles('dark', { + opacity: 0.8, + }), + }, + variants: [...thumbVariants], + }), + rail: ({ theme }) => ({ + opacity: 0.12, + backgroundColor: theme.vars.palette.grey[500], + variants: [...railVariants], + }), + track: { + variants: [...trackVariants], + }, + mark: ({ style, theme }) => ({ + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.48), + // start mark + '&[data-index="0"]': { display: 'none' }, + // end mark + ...((style?.left || style?.bottom) === '100%' && { display: 'none' }), + variants: [...markVariants], + }), + markActive: ({ theme }) => ({ + backgroundColor: varAlpha(theme.vars.palette.common.whiteChannel, 0.64), + variants: [...markActiveVariants], + }), + markLabel: ({ theme }) => ({ + fontSize: theme.typography.pxToRem(13), + color: theme.vars.palette.text.disabled, + }), + valueLabel: ({ theme }) => ({ + borderRadius: 8, + backgroundColor: theme.vars.palette.grey[800], + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.grey[700], + }), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const slider: Components = { + MuiSlider, +}; diff --git a/apps/frontend/src/theme/core/components/stack.tsx b/apps/frontend/src/theme/core/components/stack.tsx new file mode 100644 index 0000000000..b1a3c228f4 --- /dev/null +++ b/apps/frontend/src/theme/core/components/stack.tsx @@ -0,0 +1,17 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiStack: Components['MuiStack'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + useFlexGap: true, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const stack: Components = { + MuiStack, +}; diff --git a/apps/frontend/src/theme/core/components/stepper.tsx b/apps/frontend/src/theme/core/components/stepper.tsx new file mode 100644 index 0000000000..35efd83ffe --- /dev/null +++ b/apps/frontend/src/theme/core/components/stepper.tsx @@ -0,0 +1,31 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { parseCssVar } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +const MuiStepConnector: Components['MuiStepConnector'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [parseCssVar(theme.vars.palette.StepConnector.border)]: theme.vars.palette.divider, + }), + }, +}; + +const MuiStepContent: Components['MuiStepContent'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [parseCssVar(theme.vars.palette.StepContent.border)]: theme.vars.palette.divider, + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const stepper: Components = { + MuiStepConnector, + MuiStepContent, +}; diff --git a/apps/frontend/src/theme/core/components/svg-icon.tsx b/apps/frontend/src/theme/core/components/svg-icon.tsx new file mode 100644 index 0000000000..1d355c467a --- /dev/null +++ b/apps/frontend/src/theme/core/components/svg-icon.tsx @@ -0,0 +1,21 @@ +import type { Theme, Components } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +const MuiSvgIcon: Components['MuiSvgIcon'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + fontSizeLarge: { + width: 32, + height: 32, + fontSize: 'inherit', + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const svgIcon: Components = { + MuiSvgIcon, +}; diff --git a/apps/frontend/src/theme/core/components/switch.tsx b/apps/frontend/src/theme/core/components/switch.tsx new file mode 100644 index 0000000000..51ab7177d4 --- /dev/null +++ b/apps/frontend/src/theme/core/components/switch.tsx @@ -0,0 +1,126 @@ +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { switchClasses } from '@mui/material/Switch'; + +// ---------------------------------------------------------------------- + +type SwitchVariants = ComponentsVariants['MuiSwitch']; + +const DIMENSIONS: Record< + 'small' | 'medium', + { thumb: number; track: number; trackRadius: number; translateX: string } +> = { + small: { thumb: 10, track: 16, trackRadius: 8, translateX: '10px' }, + medium: { thumb: 14, track: 20, trackRadius: 10, translateX: '14px' }, +}; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const colorVariants = [ + { + props: (props) => props.color === 'default', + style: ({ theme }) => ({ + [`&.${switchClasses.checked}`]: { + [`& + .${switchClasses.track}`]: { + backgroundColor: theme.vars.palette.text.primary, + }, + [`& .${switchClasses.thumb}`]: { + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + }), + }, + }, + }), + }, +] satisfies SwitchVariants; + +const sizeVariants = [ + { + props: (props) => props.size === 'small', + style: { + [`& .${switchClasses.switchBase}`]: { + [`&.${switchClasses.checked}`]: { + transform: `translateX(${DIMENSIONS.small.translateX})`, + }, + }, + [`& .${switchClasses.thumb}`]: { + width: DIMENSIONS.small.thumb, + height: DIMENSIONS.small.thumb, + }, + [`& .${switchClasses.track}`]: { + height: DIMENSIONS.small.track, + borderRadius: DIMENSIONS.small.trackRadius, + }, + }, + }, +] satisfies SwitchVariants; + +const disabledVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${switchClasses.disabled}`]: { + [`& + .${switchClasses.track}`]: { + opacity: theme.vars.opacity.switchTrackDisabled, + }, + [`& .${switchClasses.thumb}`]: { + ...theme.applyStyles('dark', { + opacity: theme.vars.opacity.switchTrackDisabled, + }), + }, + }, + }), + }, +] satisfies SwitchVariants; + +const checkedVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${switchClasses.checked}`]: { + transform: `translateX(${DIMENSIONS.medium.translateX})`, + [`& + .${switchClasses.track}`]: { + opacity: theme.vars.opacity.switchTrack, + }, + }, + }), + }, +] satisfies SwitchVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiSwitch: Components['MuiSwitch'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + alignItems: 'center', + variants: [...sizeVariants], + }, + switchBase: { + top: 'auto', + left: '6px', + variants: [...colorVariants, ...checkedVariants, ...disabledVariants], + }, + thumb: ({ theme }) => ({ + width: DIMENSIONS.medium.thumb, + height: DIMENSIONS.medium.thumb, + color: theme.vars.palette.common.white, + }), + track: ({ theme }) => ({ + height: DIMENSIONS.medium.track, + borderRadius: DIMENSIONS.medium.trackRadius, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.48), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const switches: Components = { + MuiSwitch, +}; diff --git a/apps/frontend/src/theme/core/components/table.tsx b/apps/frontend/src/theme/core/components/table.tsx new file mode 100644 index 0000000000..4d0b88c627 --- /dev/null +++ b/apps/frontend/src/theme/core/components/table.tsx @@ -0,0 +1,94 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { tableRowClasses } from '@mui/material/TableRow'; +import { tableCellClasses } from '@mui/material/TableCell'; + +// ---------------------------------------------------------------------- + +const MuiTableContainer: Components['MuiTableContainer'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...theme.mixins.scrollbarStyles(theme), + position: 'relative', + }), + }, +}; + +const MuiTableRow: Components['MuiTableRow'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + [`&.${tableRowClasses.selected}`]: { + backgroundColor: varAlpha(theme.vars.palette.primary.darkChannel, 0.04), + '&:hover': { + backgroundColor: varAlpha(theme.vars.palette.primary.darkChannel, 0.08), + }, + }, + '&:last-of-type': { + [`& .${tableCellClasses.root}`]: { + border: 0, + }, + }, + }), + }, +}; + +const MuiTableCell: Components['MuiTableCell'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + borderBottomStyle: 'dashed', + }, + head: ({ theme }) => ({ + fontSize: theme.typography.pxToRem(14), + color: theme.vars.palette.text.secondary, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.vars.palette.background.neutral, + }), + stickyHeader: ({ theme }) => ({ + backgroundColor: theme.vars.palette.background.paper, + backgroundImage: `linear-gradient(to bottom, ${theme.vars.palette.background.neutral}, ${theme.vars.palette.background.neutral})`, + }), + paddingCheckbox: ({ theme }) => ({ + paddingLeft: theme.spacing(1), + }), + }, +}; + +const MuiTablePagination: Components['MuiTablePagination'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + backIconButtonProps: { size: 'small' }, + nextIconButtonProps: { size: 'small' }, + slotProps: { select: { name: 'table-pagination-select' } }, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { width: '100%' }, + toolbar: { height: 64 }, + actions: { marginRight: 8 }, + select: { + display: 'flex', + alignItems: 'center', + }, + selectIcon: { + right: 4, + width: 16, + height: 16, + top: 'calc(50% - 8px)', + }, + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const table: Components = { + MuiTableRow, + MuiTableCell, + MuiTableContainer, + MuiTablePagination, +}; diff --git a/apps/frontend/src/theme/core/components/tabs.tsx b/apps/frontend/src/theme/core/components/tabs.tsx new file mode 100644 index 0000000000..3c3a523827 --- /dev/null +++ b/apps/frontend/src/theme/core/components/tabs.tsx @@ -0,0 +1,199 @@ +import type { TabsClassKey } from '@mui/material/Tabs'; +import type { Theme, Components, ComponentsVariants } from '@mui/material/styles'; + +import { tabClasses } from '@mui/material/Tab'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ +export type TabsExtendIndicatorColor = { custom: true; inherit: true }; + +type TabsVariants = ComponentsVariants['MuiTabs']; +type TabVariants = ComponentsVariants['MuiTab']; + +const customTabsIndicatorStyles = { + root: (theme: Theme) => { + const cssVars = { + '--item-padding-x': '16px', + '--list-padding-x': '8px', + '--indicator-radius': '8px', + '--indicator-shadow': theme.vars.customShadows.z1, + '--indicator-bg': theme.vars.palette.common.white, + ...theme.applyStyles('dark', { + '--indicator-bg': theme.vars.palette.grey[900], + }), + }; + + return { + ...cssVars, + backgroundColor: theme.vars.palette.background.neutral, + [`& .${tabClasses.root}`]: { + zIndex: 1, + minHeight: 52, + paddingLeft: 'var(--item-padding-x)', + paddingRight: 'var(--item-padding-x)', + }, + }; + }, + listHorizontal: { + height: '100%', + paddingLeft: 'var(--list-padding-x)', + paddingRight: 'var(--list-padding-x)', + }, + listVertical: { + paddingTop: 'var(--list-padding-x)', + paddingBottom: 'var(--list-padding-x)', + }, + indicator: { + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + '&::before': { + content: '""', + width: '100%', + boxShadow: 'var(--indicator-shadow)', + backgroundColor: 'var(--indicator-bg)', + borderRadius: 'var(--indicator-radius)', + height: 'calc(100% - calc(var(--list-padding-x) * 2))', + }, + }, + indicatorVertical: { + width: '100%', + '&::before': { + height: '100%', + width: 'calc(100% - calc(var(--list-padding-x) * 2))', + }, + }, +}; + +/* ********************************************************************** + * 🗳️ Variants + * **********************************************************************/ +const tabsVariants = { + root: [ + { + props: (props) => props.textColor === 'inherit', + style: { + [`& .${tabClasses.root}`]: { + [`&.${tabClasses.selected}`]: { + color: 'inherit', + }, + }, + }, + }, + { + props: (props) => props.indicatorColor === 'custom', + style: ({ theme }) => ({ ...customTabsIndicatorStyles.root(theme) }), + }, + ], + list: [ + { + props: (props) => + props.indicatorColor !== 'custom' && + props.variant !== 'fullWidth' && + props.orientation !== 'vertical', + style: ({ theme }) => ({ + gap: theme.spacing(5), + [theme.breakpoints.down('sm')]: { gap: theme.spacing(3) }, + }), + }, + { + props: (props) => props.indicatorColor === 'custom' && props.orientation === 'horizontal', + style: { ...customTabsIndicatorStyles.listHorizontal }, + }, + { + props: (props) => props.indicatorColor === 'custom' && props.orientation === 'vertical', + style: { ...customTabsIndicatorStyles.listVertical }, + }, + ], + indicator: [ + { + props: (props) => props.indicatorColor === 'inherit', + style: { backgroundColor: 'currentColor' }, + }, + { + props: (props) => props.indicatorColor === 'custom', + style: { ...customTabsIndicatorStyles.indicator }, + }, + { + props: (props) => props.indicatorColor === 'custom' && props.orientation === 'vertical', + style: { ...customTabsIndicatorStyles.indicatorVertical }, + }, + ], +} satisfies Partial>; + +const tabVariants = [ + { + props: {}, + style: ({ theme }) => ({ + [`&.${tabClasses.selected}`]: { + fontWeight: theme.typography.fontWeightSemiBold, + }, + }), + }, + { + props: (props) => !!props.icon && !!props.label, + style: { + minHeight: 'auto', + }, + }, +] satisfies TabVariants; + +/* ********************************************************************** + * 🧩 Components + * **********************************************************************/ +const MuiTabs: Components['MuiTabs'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + variant: 'scrollable', + textColor: 'inherit', + indicatorColor: 'inherit', + allowScrollButtonsMobile: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: { + variants: [...tabsVariants.root], + }, + list: { + variants: [...tabsVariants.list], + }, + indicator: { + variants: [...tabsVariants.indicator], + }, + }, +}; + +const MuiTab: Components['MuiTab'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + disableRipple: true, + iconPosition: 'start', + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + opacity: 1, + minWidth: 48, + paddingLeft: 0, + paddingRight: 0, + color: theme.vars.palette.text.secondary, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: theme.typography.body2.lineHeight, + variants: [...tabVariants], + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const tabs: Components = { + MuiTab, + MuiTabs, +}; diff --git a/apps/frontend/src/theme/core/components/text-field.tsx b/apps/frontend/src/theme/core/components/text-field.tsx new file mode 100644 index 0000000000..97137a63c8 --- /dev/null +++ b/apps/frontend/src/theme/core/components/text-field.tsx @@ -0,0 +1,345 @@ +import type { InputBaseClasses } from '@mui/material/InputBase'; +import type { OutlinedInputClasses } from '@mui/material/OutlinedInput'; +import type { PickerTextFieldOwnerState } from '@mui/x-date-pickers/models'; +import type { FilledInputProps, FilledInputClasses } from '@mui/material/FilledInput'; +import type { Theme, CSSObject, Components, ComponentsVariants } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { inputBaseClasses } from '@mui/material/InputBase'; +import { filledInputClasses } from '@mui/material/FilledInput'; +import { outlinedInputClasses } from '@mui/material/OutlinedInput'; +import { inputAdornmentClasses } from '@mui/material/InputAdornment'; + +// ---------------------------------------------------------------------- + +type InputContext = 'standard' | 'picker'; + +type InputSizeProps = Pick & { + ownerState?: PickerTextFieldOwnerState; +}; + +type InputBaseVariants = ComponentsVariants['MuiInputBase']; +type PickersInputBaseVariants = + | InputBaseVariants + | ComponentsVariants['MuiPickersInputBase']; + +type OutlinedInputVariants = ComponentsVariants['MuiOutlinedInput']; +type PickersOutlinedInputVariants = + | OutlinedInputVariants + | ComponentsVariants['MuiPickersOutlinedInput']; + +type FilledInputVariants = ComponentsVariants['MuiFilledInput']; +type PickersFilledInputVariants = + | FilledInputVariants + | ComponentsVariants['MuiPickersFilledInput']; + +export const INPUT_TYPOGRAPHY = { + fontSize: { base: 15, responsive: 16 }, + lineHeight: 24, +} as const; + +export const INPUT_PADDING: Record> = { + base: { + small: { paddingTop: 0, paddingBottom: 4 }, + medium: { paddingTop: 4, paddingBottom: 4 }, + }, + outlined: { + small: { paddingTop: 8, paddingBottom: 8 }, + medium: { paddingTop: 16, paddingBottom: 16 }, + }, + filled: { + small: { paddingTop: 20 }, + medium: { paddingTop: 24 }, + smallHidden: { paddingTop: 8, paddingBottom: 8 }, + mediumHidden: { paddingTop: 16, paddingBottom: 16 }, + }, +}; + +export function getInputTypography( + theme: Theme, + keys: Array<'fontSize' | 'height' | 'lineHeight'> +): CSSObject { + const { fontSize, lineHeight } = INPUT_TYPOGRAPHY; + + const baseStyles = { + fontSize: theme.typography.pxToRem(fontSize.base), + height: `${lineHeight}px`, + lineHeight: `${lineHeight}px`, + }; + + const responsiveStyles = { + fontSize: theme.typography.pxToRem(fontSize.responsive), + height: `${lineHeight}px`, + lineHeight: `${lineHeight}px`, + }; + + return { + ...Object.fromEntries(keys.map((k) => [k, baseStyles[k]])), + [theme.breakpoints.down('sm')]: Object.fromEntries(keys.map((k) => [k, responsiveStyles[k]])), + }; +} + +/* ********************************************************************** + * 🧩 InputBase + * **********************************************************************/ +export const inputBaseStyles = { + root: (context: InputContext, theme: Theme, classes: Partial): CSSObject => ({ + '--disabled-color': theme.vars.palette.action.disabled, + ...getInputTypography(theme, ['lineHeight']), + [`&.${classes.disabled}`]: { + [`& .${inputAdornmentClasses.root} *`]: { color: 'var(--disabled-color)' }, + [`& .${classes.input}`]: { + ...(context === 'standard' && { WebkitTextFillColor: 'var(--disabled-color)' }), + ...(context === 'picker' && { '& span': { color: 'var(--disabled-color)' } }), + }, + }, + }), + input: (context: InputContext, theme: Theme): CSSObject => ({ + ...(context === 'standard' && { + ...getInputTypography(theme, ['fontSize', 'height', 'lineHeight']), + '&:focus': { borderRadius: 'inherit' }, + '&::placeholder, &::-webkit-input-placeholder, &::-moz-placeholder, &:-ms-input-placeholder, &::-ms-input-placeholder': + { color: theme.vars.palette.text.disabled }, + }), + ...(context === 'picker' && { + ...getInputTypography(theme, ['fontSize', 'lineHeight']), + '& span': { lineHeight: 'inherit' }, + }), + }), +}; + +export const inputBaseVariants = { + root: [ + { + props: (props) => !!props.multiline, + style: { ...INPUT_PADDING.base.medium }, + }, + { + props: (props) => !!props.multiline && props.size === 'small', + style: { ...INPUT_PADDING.base.small }, + }, + ], + input: [ + { + props: {}, + style: { ...INPUT_PADDING.base.medium }, + }, + { + props: ({ size, ownerState }: InputSizeProps) => (size || ownerState?.inputSize) === 'small', + style: { ...INPUT_PADDING.base.small }, + }, + ], +} satisfies { + root: InputBaseVariants; + input: PickersInputBaseVariants; +}; + +const multilineInputVariants = [ + { + props: (props) => !!props.multiline, + style: { padding: 0 }, + }, +] satisfies InputBaseVariants; + +const MuiInputBase: Components['MuiInputBase'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...inputBaseStyles.root('standard', theme, inputBaseClasses), + variants: inputBaseVariants.root, + }), + input: ({ theme }) => ({ + ...inputBaseStyles.input('standard', theme), + variants: [...inputBaseVariants.input, ...multilineInputVariants], + }), + }, +}; + +/* ********************************************************************** + * 🧩 Input + * **********************************************************************/ +export const inputStyles = { + root: (theme: Theme): CSSObject => ({ + '&::before': { + borderBottomColor: theme.vars.palette.shared.inputUnderline, + }, + '&::after': { + borderBottomColor: theme.vars.palette.text.primary, + }, + }), +}; + +const MuiInput: Components['MuiInput'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => inputStyles.root(theme), + }, +}; + +/* ********************************************************************** + * 🧩 OutlinedInput + * **********************************************************************/ +export const outlinedInputStyles = { + root: (theme: Theme, classes: Partial): CSSObject => ({ + [`&.${classes.focused}:not(.${classes.error})`]: { + [`& .${classes.notchedOutline}`]: { + borderColor: theme.vars.palette.text.primary, + }, + }, + [`&.${classes.disabled}`]: { + [`& .${classes.notchedOutline}`]: { + borderColor: theme.vars.palette.action.disabledBackground, + }, + }, + }), + notchedOutline: (theme: Theme): CSSObject => ({ + borderColor: theme.vars.palette.shared.inputOutlined, + transition: theme.transitions.create(['border-color'], { + duration: theme.transitions.duration.shortest, + }), + }), +}; + +export const outlinedInputVariants = { + root: [ + { + props: (props) => !!props.multiline, + style: { ...INPUT_PADDING.outlined.medium }, + }, + { + props: (props) => !!props.multiline && props.size === 'small', + style: { ...INPUT_PADDING.outlined.small }, + }, + ], + input: [ + { + props: {}, + style: { ...INPUT_PADDING.outlined.medium }, + }, + { + props: ({ size, ownerState }: InputSizeProps) => (size || ownerState?.inputSize) === 'small', + style: { ...INPUT_PADDING.outlined.small }, + }, + ], +} satisfies { + root: OutlinedInputVariants; + input: PickersOutlinedInputVariants; +}; + +const MuiOutlinedInput: Components['MuiOutlinedInput'] = { + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...outlinedInputStyles.root(theme, outlinedInputClasses), + variants: outlinedInputVariants.root, + }), + input: { variants: [...outlinedInputVariants.input, ...multilineInputVariants] }, + notchedOutline: ({ theme }) => outlinedInputStyles.notchedOutline(theme), + }, +}; + +/* ********************************************************************** + * 🧩 FilledInput + * **********************************************************************/ +export const filledInputStyles = { + root: (theme: Theme, classes: Partial): CSSObject => { + const baseBg = varAlpha(theme.vars.palette.grey['500Channel'], 0.08); + const hoverBg = varAlpha(theme.vars.palette.grey['500Channel'], 0.16); + const errorBg = varAlpha(theme.vars.palette.error.mainChannel, 0.08); + const errorHoverBg = varAlpha(theme.vars.palette.error.mainChannel, 0.16); + const disabledBg = theme.vars.palette.action.disabledBackground; + + return { + backgroundColor: baseBg, + borderRadius: theme.shape.borderRadius, + [`&:hover, &.${classes.focused}`]: { backgroundColor: hoverBg }, + [`&.${classes.error}`]: { + backgroundColor: errorBg, + [`&:hover, &.${classes.focused}`]: { backgroundColor: errorHoverBg }, + }, + [`&.${classes.disabled}`]: { backgroundColor: disabledBg }, + }; + }, +}; + +export const filledInputVariants = { + root: [ + { + props: (props) => !!props.multiline, + style: { ...INPUT_PADDING.filled.medium }, + }, + { + props: (props) => !!props.multiline && props.size === 'small', + style: { ...INPUT_PADDING.filled.small }, + }, + { + props: (props) => !!props.multiline && !!props.hiddenLabel, + style: { ...INPUT_PADDING.filled.mediumHidden }, + }, + { + props: (props) => !!props.multiline && !!props.hiddenLabel && props.size === 'small', + style: { ...INPUT_PADDING.filled.smallHidden }, + }, + ], + input: [ + { + props: {}, + style: { ...INPUT_PADDING.filled.medium }, + }, + { + props: ({ size, ownerState }: InputSizeProps) => (size || ownerState?.inputSize) === 'small', + style: { ...INPUT_PADDING.filled.small }, + }, + { + props: ({ hiddenLabel }: InputSizeProps) => !!hiddenLabel, + style: { ...INPUT_PADDING.filled.mediumHidden }, + }, + { + props: ({ size, hiddenLabel, ownerState }: InputSizeProps) => + !!hiddenLabel && (size || ownerState?.inputSize) === 'small', + style: { ...INPUT_PADDING.filled.smallHidden }, + }, + ], +} satisfies { + root: FilledInputVariants; + input: PickersFilledInputVariants; +}; + +const MuiFilledInput: Components['MuiFilledInput'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + disableUnderline: true, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + root: ({ theme }) => ({ + ...filledInputStyles.root(theme, filledInputClasses), + variants: filledInputVariants.root, + }), + input: { + variants: [...filledInputVariants.input, ...multilineInputVariants], + }, + }, +}; + +/* ********************************************************************** + * 🧩 TextField + * **********************************************************************/ +const MuiTextField: Components['MuiTextField'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + variant: 'outlined', + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const textField: Components = { + MuiInput, + MuiInputBase, + MuiTextField, + MuiFilledInput, + MuiOutlinedInput, +}; diff --git a/apps/frontend/src/theme/core/components/tooltip.tsx b/apps/frontend/src/theme/core/components/tooltip.tsx new file mode 100644 index 0000000000..46a28f4486 --- /dev/null +++ b/apps/frontend/src/theme/core/components/tooltip.tsx @@ -0,0 +1,40 @@ +import type { Theme, Components } from '@mui/material/styles'; + +import { parseCssVar } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +const MuiTooltip: Components['MuiTooltip'] = { + // ▼▼▼▼▼▼▼▼ ⚙️ PROPS ▼▼▼▼▼▼▼▼ + defaultProps: { + slotProps: { + popper: { + modifiers: [ + { + name: 'offset', + options: { + offset: [0, -4], + }, + }, + ], + }, + }, + }, + // ▼▼▼▼▼▼▼▼ 🎨 STYLE ▼▼▼▼▼▼▼▼ + styleOverrides: { + tooltip: ({ theme }) => ({ + borderRadius: Number(theme.shape.borderRadius) * 0.75, + [parseCssVar(theme.vars.palette.Tooltip.bg)]: theme.vars.palette.grey[800], + ...theme.applyStyles('dark', { + [parseCssVar(theme.vars.palette.Tooltip.bg)]: theme.vars.palette.grey[700], + }), + }), + }, +}; + +/* ********************************************************************** + * 🚀 Export + * **********************************************************************/ +export const tooltip: Components = { + MuiTooltip, +}; diff --git a/apps/frontend/src/theme/core/custom-shadows.ts b/apps/frontend/src/theme/core/custom-shadows.ts new file mode 100644 index 0000000000..452c9fcf1f --- /dev/null +++ b/apps/frontend/src/theme/core/custom-shadows.ts @@ -0,0 +1,68 @@ +import type { SchemesRecord } from '../types'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { grey, info, error, common, primary, success, warning, secondary } from './palette'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../extend-theme-types.d.ts} + */ + +export type CustomShadows = { + z1: string; + z4: string; + z8: string; + z12: string; + z16: string; + z20: string; + z24: string; + primary: string; + secondary: string; + info: string; + success: string; + warning: string; + error: string; + card: string; + dialog: string; + dropdown: string; +}; + +// ---------------------------------------------------------------------- + +export function createShadowColor(colorChannel: string): string { + return `0 8px 16px 0 ${varAlpha(colorChannel, 0.24)}`; +} + +function createCustomShadows(colorChannel: string): CustomShadows { + return { + z1: `0 1px 2px 0 ${varAlpha(colorChannel, 0.16)}`, + z4: `0 4px 8px 0 ${varAlpha(colorChannel, 0.16)}`, + z8: `0 8px 16px 0 ${varAlpha(colorChannel, 0.16)}`, + z12: `0 12px 24px -4px ${varAlpha(colorChannel, 0.16)}`, + z16: `0 16px 32px -4px ${varAlpha(colorChannel, 0.16)}`, + z20: `0 20px 40px -4px ${varAlpha(colorChannel, 0.16)}`, + z24: `0 24px 48px 0 ${varAlpha(colorChannel, 0.16)}`, + /********/ + dialog: `-40px 40px 80px -8px ${varAlpha(common.blackChannel, 0.24)}`, + card: `0 0 2px 0 ${varAlpha(colorChannel, 0.2)}, 0 12px 24px -4px ${varAlpha(colorChannel, 0.12)}`, + dropdown: `0 0 2px 0 ${varAlpha(colorChannel, 0.24)}, -20px 20px 40px -4px ${varAlpha(colorChannel, 0.24)}`, + /********/ + primary: createShadowColor(primary.mainChannel), + secondary: createShadowColor(secondary.mainChannel), + info: createShadowColor(info.mainChannel), + success: createShadowColor(success.mainChannel), + warning: createShadowColor(warning.mainChannel), + error: createShadowColor(error.mainChannel), + }; +} + +/* ********************************************************************** + * 📦 Final + * **********************************************************************/ +export const customShadows: SchemesRecord = { + light: createCustomShadows(grey['500Channel']), + dark: createCustomShadows(common.blackChannel), +}; diff --git a/apps/frontend/src/theme/core/index.ts b/apps/frontend/src/theme/core/index.ts new file mode 100644 index 0000000000..65cd9d3a0d --- /dev/null +++ b/apps/frontend/src/theme/core/index.ts @@ -0,0 +1,11 @@ +export * from './mixins'; + +export * from './shadows'; + +export * from './palette'; + +export * from './typography'; + +export * from './components'; + +export * from './custom-shadows'; diff --git a/apps/frontend/src/theme/core/mixins/background.ts b/apps/frontend/src/theme/core/mixins/background.ts new file mode 100644 index 0000000000..37c18e5f9c --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/background.ts @@ -0,0 +1,109 @@ +import type { CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * Creates a CSS object for layered background gradients or images. + * + * @param images - Gradient strings or image URLs. + * @param sizes - (Optional) Background sizes for each layer. Defaults to 'cover'. + * @param positions - (Optional) Background positions for each layer. Defaults to 'center'. + * @param repeats - (Optional) Background repeat settings for each layer. Defaults to 'no-repeat'. + * @returns A CSS object with styles. + * + * @example + * // With gradient and image overlay + * ...theme.mixins.bgGradient({ + * images: [ + * `linear-gradient(0deg, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)}, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)})`, + * `url(/assets/overlay.png)`, + * ], + * sizes: ['cover', '80px 80px'], + * positions: ['center', 'top right'], + * repeats: ['no-repeat', 'repeat'] + * }) + * + * @example + * // With a single gradient only + * ...theme.mixins.bgGradient({ + * images: [ + * `linear-gradient(0deg, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)}, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)})`, + * ], + * }) + */ + +export type BgGradientProps = { + images: string[]; + sizes?: string[]; + positions?: string[]; + repeats?: string[]; +}; + +export function bgGradient({ sizes, repeats, images, positions }: BgGradientProps): CSSObject { + return { + backgroundImage: images?.join(', '), + backgroundSize: sizes?.join(', ') ?? 'cover', + backgroundRepeat: repeats?.join(', ') ?? 'no-repeat', + backgroundPosition: positions?.join(', ') ?? 'center', + }; +} + +// ---------------------------------------------------------------------- + +/** + * Creates a CSS object for a blurred background effect with optional image overlay. + * + * @param color - Background color with optional transparency. + * @param blur - (Optional) Blur intensity in pixels. Defaults to 6. + * @param imgUrl - (Optional) Background image URL to apply the blur effect on. + * @returns A CSS object with styles. + * + * @example + * // With image overlay + * ...theme.mixins.bgBlur({ + * color: varAlpha(theme.vars.palette.background.paperChannel, 0.8), + * imgUrl: '/assets/overlay.png', + * blur: 8, + * }) + * + * @example + * // With color only + * ...theme.mixins.bgBlur({ + * color: varAlpha(theme.vars.palette.background.paperChannel, 0.8), + * }) + */ + +export type BgBlurProps = { + color: string; + blur?: number; + imgUrl?: string; +}; + +export function bgBlur({ color, blur = 6, imgUrl }: BgBlurProps): CSSObject { + if (imgUrl) { + return { + position: 'relative', + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + backgroundImage: `url(${imgUrl})`, + '&::before': { + position: 'absolute', + top: 0, + left: 0, + zIndex: 9, + content: '""', + width: '100%', + height: '100%', + backdropFilter: `blur(${blur}px)`, + WebkitBackdropFilter: `blur(${blur}px)`, + backgroundColor: color, + }, + }; + } + return { + backdropFilter: `blur(${blur}px)`, + WebkitBackdropFilter: `blur(${blur}px)`, + backgroundColor: color, + }; +} diff --git a/apps/frontend/src/theme/core/mixins/border.ts b/apps/frontend/src/theme/core/mixins/border.ts new file mode 100644 index 0000000000..536e487fc2 --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/border.ts @@ -0,0 +1,45 @@ +import type { CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * Creates a CSS object for a gradient border. + * + * @param color - (Optional) The border color or CSS gradient definition. Defaults to `undefined`. + * @param padding - (Optional) Padding inside the border. Defaults to `'2px'`. + * @returns A CSS object with styles. + * + * @example + * ...theme.mixins.borderGradient({ + * color: `to right, ${theme.vars.palette.primary.main}, ${varAlpha(theme.vars.palette.primary.mainChannel, 0.2)}`, + * padding: '4px' + * }) + */ + +export type BorderGradientProps = { + color?: string; + padding?: string; +}; + +export function borderGradient(props?: BorderGradientProps): CSSObject { + const { color, padding = '2px' } = props ?? {}; + + return { + padding, + inset: 0, + width: '100%', + content: '""', + height: '100%', + margin: 'auto', + position: 'absolute', + borderRadius: 'inherit', + /********/ + mask: 'linear-gradient(#FFF 0 0) content-box, linear-gradient(#FFF 0 0)', + WebkitMask: 'linear-gradient(#FFF 0 0) content-box, linear-gradient(#FFF 0 0)', + maskComposite: 'exclude', + WebkitMaskComposite: 'xor', + ...(color && { + background: color, + }), + }; +} diff --git a/apps/frontend/src/theme/core/mixins/global-styles-components.ts b/apps/frontend/src/theme/core/mixins/global-styles-components.ts new file mode 100644 index 0000000000..af890ff937 --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/global-styles-components.ts @@ -0,0 +1,284 @@ +import type { Theme, CSSObject } from '@mui/material/styles'; +import type { PaletteColorKey, CommonColorsKeys } from '../palette'; + +import { varAlpha, noRtlFlip } from 'minimal-shared/utils'; + +import { dividerClasses } from '@mui/material/Divider'; +import { checkboxClasses } from '@mui/material/Checkbox'; +import { menuItemClasses } from '@mui/material/MenuItem'; +import { autocompleteClasses } from '@mui/material/Autocomplete'; + +// ---------------------------------------------------------------------- + +/** + * Generates styles for menu item components. + * + * @param theme - The MUI theme object. + * @returns A CSS object with styles. + * + * @example + * ...theme.mixins.menuItemStyles(theme) + */ + +export function menuItemStyles(theme: Theme): CSSObject { + return { + ...theme.typography.body2, + padding: theme.spacing(0.75, 1), + borderRadius: Number(theme.shape.borderRadius) * 0.75, + '&:not(:last-of-type)': { + marginBottom: 4, + }, + [`&.${menuItemClasses.selected}`]: { + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.vars.palette.action.selected, + '&:hover': { backgroundColor: theme.vars.palette.action.hover }, + }, + [`& .${checkboxClasses.root}`]: { + padding: theme.spacing(0.5), + marginLeft: theme.spacing(-0.5), + marginRight: theme.spacing(0.5), + }, + [`&.${autocompleteClasses.option}[aria-selected="true"]`]: { + backgroundColor: theme.vars.palette.action.selected, + '&:hover': { backgroundColor: theme.vars.palette.action.hover }, + }, + [`&+.${dividerClasses.root}`]: { + margin: theme.spacing(0.5, 0), + }, + }; +} + +// ---------------------------------------------------------------------- + +/** + * Generates styles for paper components. + * + * @param theme - The MUI theme object. + * @param options.blur - (Optional) Blur intensity in pixels. Defaults to 20. + * @param options.color - (Optional) Background color. Defaults to semi-transparent paper color. + * @param options.dropdown - (Optional) If true, applies padding, box-shadow, and border-radius for dropdowns. + * @returns A CSS object with styles. + * + * @example + * // Paper with default styles + * ...theme.mixins.paperStyles(theme); + * + * @example + * // Paper with dropdown styles and custom blur + * ...theme.mixins.paperStyles(theme, { + * blur: 10, + * color: varAlpha(theme.vars.palette.background.defaultChannel, 0.9), + * dropdown: true + * }) + */ + +export type PaperStyleOptions = { + blur?: number; + color?: string; + dropdown?: boolean; +}; + +/** + * Tools for creating image base64 + * https://www.fffuel.co/eeencode/ + */ +const cyanShape = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjAiIGhlaWdodD0iMTIwIiBmaWxsPSJ1cmwoI3BhaW50MF9yYWRpYWxfNDQ2NF81NTMzOCkiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CjxkZWZzPgo8cmFkaWFsR3JhZGllbnQgaWQ9InBhaW50MF9yYWRpYWxfNDQ2NF81NTMzOCIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09InRyYW5zbGF0ZSgxMjAgMS44MTgxMmUtMDUpIHJvdGF0ZSgtNDUpIHNjYWxlKDEyMy4yNSkiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMDBCOEQ5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwQjhEOSIgc3RvcC1vcGFjaXR5PSIwIi8+CjwvcmFkaWFsR3JhZGllbnQ+CjwvZGVmcz4KPC9zdmc+Cg=='; + +const redShape = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjAiIGhlaWdodD0iMTIwIiBmaWxsPSJ1cmwoI3BhaW50MF9yYWRpYWxfNDQ2NF81NTMzNykiIGZpbGwtb3BhY2l0eT0iMC4xIi8+CjxkZWZzPgo8cmFkaWFsR3JhZGllbnQgaWQ9InBhaW50MF9yYWRpYWxfNDQ2NF81NTMzNyIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEyMCkgcm90YXRlKDEzNSkgc2NhbGUoMTIzLjI1KSI+CjxzdG9wIHN0b3AtY29sb3I9IiNGRjU2MzAiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRkY1NjMwIiBzdG9wLW9wYWNpdHk9IjAiLz4KPC9yYWRpYWxHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K'; + +export function paperStyles(theme: Theme, options?: PaperStyleOptions): CSSObject { + const { blur = 20, color, dropdown } = options ?? {}; + + return { + ...theme.mixins.bgGradient({ + images: [`url(${cyanShape})`, `url(${redShape})`], + sizes: ['50%', '50%'], + positions: [noRtlFlip('top right'), noRtlFlip('left bottom')], + }), + backdropFilter: `blur(${blur}px)`, + WebkitBackdropFilter: `blur(${blur}px)`, + backgroundColor: color ?? varAlpha(theme.vars.palette.background.paperChannel, 0.9), + ...(dropdown && { + padding: theme.spacing(0.5), + boxShadow: theme.vars.customShadows.dropdown, + borderRadius: `${Number(theme.shape.borderRadius) * 1.25}px`, + }), + }; +} + +// ---------------------------------------------------------------------- + +/** + * Generate style variant for components like Button, Chip, Label, etc. + * + * @param theme - The MUI theme object. + * @param colorKey - 'default', 'inherit', or a palette color key like 'primary', 'secondary', etc. + * @param options.hover - (Optional) Enable hover styles or provide custom hover styles. + * @returns A CSS object with styles. + * + * @example + * // Filled styles + * ...theme.mixins.filledStyles(theme, 'inherit', { hover: true }) + * ...theme.mixins.filledStyles(theme, 'inherit', { hover: { boxShadow: theme.vars.customShadows.z8 }, }) + * + * // Soft styles + * ...theme.mixins.softStyles(theme, 'inherit') + * ...theme.mixins.softStyles(theme, 'primary', { hover: true }) + */ + +export type ColorKey = CommonColorsKeys | PaletteColorKey | 'default' | 'inherit'; + +export type StyleOptions = { + hover?: boolean | CSSObject; +}; + +function getHoverStyles(hoverOption: StyleOptions['hover'], hoverBase: CSSObject): CSSObject { + if (!hoverOption) return {}; + + return { + '&:hover': { + ...hoverBase, + ...(typeof hoverOption === 'object' ? hoverOption : {}), + }, + }; +} + +export function filledStyles(theme: Theme, colorKey: ColorKey, options?: StyleOptions): CSSObject { + if (!colorKey) { + console.warn( + '[filledStyles] Missing colorKey. Please provide a valid color such as "primary", "black", or "default".' + ); + return {}; + } + + if (colorKey === 'default') { + const base: CSSObject = { + color: theme.vars.palette.grey[800], + backgroundColor: theme.vars.palette.grey[300], + }; + + const hover: CSSObject = getHoverStyles(options?.hover, { + backgroundColor: theme.vars.palette.grey[400], + }); + + return { ...base, ...hover }; + } + + if (colorKey === 'inherit') { + const base: CSSObject = { + color: theme.vars.palette.common.white, + backgroundColor: theme.vars.palette.grey[800], + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + backgroundColor: theme.vars.palette.common.white, + }), + }; + + const hover: CSSObject = getHoverStyles(options?.hover, { + backgroundColor: theme.vars.palette.grey[700], + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.grey[400], + }), + }); + + return { ...base, ...hover }; + } + + if (colorKey === 'white' || colorKey === 'black') { + const base: CSSObject = { + color: `${theme.vars.palette.common[colorKey === 'white' ? 'black' : 'white']}`, + backgroundColor: theme.vars.palette.common[colorKey], + }; + + const hover: CSSObject = getHoverStyles(options?.hover, { + backgroundColor: varAlpha( + `${theme.vars.palette.common[`${colorKey}Channel`]}`, + theme.vars.opacity.filled.commonHoverBg + ), + }); + + return { ...base, ...hover }; + } + + const colorPalette: Record<'base' | 'hover', CSSObject> = { + base: { + color: theme.vars.palette[colorKey].contrastText, + backgroundColor: theme.vars.palette[colorKey].main, + }, + hover: getHoverStyles(options?.hover, { + backgroundColor: theme.vars.palette[colorKey].dark, + }), + }; + + return { ...colorPalette.base, ...colorPalette.hover }; +} + +export function softStyles(theme: Theme, colorKey: ColorKey, options?: StyleOptions): CSSObject { + if (!colorKey) { + console.warn( + '[softStyles] Missing colorKey. Please provide a valid color such as "primary", "black", or "default".' + ); + return {}; + } + + if (colorKey === 'default') { + return { + ...filledStyles(theme, 'default', options), + boxShadow: 'none', + }; + } + + if (colorKey === 'inherit') { + const base: CSSObject = { + boxShadow: 'none', + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], theme.vars.opacity.soft.bg), + }; + + const hover: CSSObject = getHoverStyles(options?.hover, { + backgroundColor: varAlpha( + theme.vars.palette.grey['500Channel'], + theme.vars.opacity.soft.hoverBg + ), + }); + + return { ...base, ...hover }; + } + + if (colorKey === 'white' || colorKey === 'black') { + const base: CSSObject = { + boxShadow: 'none', + color: theme.vars.palette.common[colorKey], + backgroundColor: varAlpha('currentColor', theme.vars.opacity.soft.commonBg), + }; + + const hover: CSSObject = getHoverStyles(options?.hover, { + backgroundColor: varAlpha('currentColor', theme.vars.opacity.soft.commonHoverBg), + }); + + return { ...base, ...hover }; + } + + const colorPalette: Record<'base' | 'hover', CSSObject> = { + base: { + boxShadow: 'none', + color: theme.vars.palette[colorKey].dark, + backgroundColor: varAlpha( + theme.vars.palette[colorKey].mainChannel, + theme.vars.opacity.soft.bg + ), + ...theme.applyStyles('dark', { + color: theme.vars.palette[colorKey].light, + }), + }, + hover: getHoverStyles(options?.hover, { + backgroundColor: varAlpha( + theme.vars.palette[colorKey].mainChannel, + theme.vars.opacity.soft.hoverBg + ), + }), + }; + + return { ...colorPalette.base, ...colorPalette.hover }; +} diff --git a/apps/frontend/src/theme/core/mixins/index.ts b/apps/frontend/src/theme/core/mixins/index.ts new file mode 100644 index 0000000000..2b3d8cffd6 --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/index.ts @@ -0,0 +1 @@ +export * from './mixins'; diff --git a/apps/frontend/src/theme/core/mixins/mixins.ts b/apps/frontend/src/theme/core/mixins/mixins.ts new file mode 100644 index 0000000000..6a91a69ec7 --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/mixins.ts @@ -0,0 +1,66 @@ +import type { Theme, CSSObject, MixinsOptions } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { borderGradient } from './border'; +import { maxLine, textGradient } from './text'; +import { bgBlur, bgGradient } from './background'; +import { softStyles, paperStyles, filledStyles, menuItemStyles } from './global-styles-components'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../../extend-theme-types.d.ts} + */ + +export type * from './text'; +export type * from './border'; +export type * from './background'; +export type * from './global-styles-components'; + +export type MixinsExtend = { + hideScrollX: CSSObject; + hideScrollY: CSSObject; + scrollbarStyles: (theme: Theme) => CSSObject; + bgBlur: typeof bgBlur; + maxLine: typeof maxLine; + bgGradient: typeof bgGradient; + softStyles: typeof softStyles; + paperStyles: typeof paperStyles; + textGradient: typeof textGradient; + filledStyles: typeof filledStyles; + borderGradient: typeof borderGradient; + menuItemStyles: typeof menuItemStyles; +}; + +/* ********************************************************************** + * 📦 Final + * **********************************************************************/ +export const mixins: MixinsOptions = { + hideScrollX: { + msOverflowStyle: 'none', + scrollbarWidth: 'none', + overflowX: 'auto', + '&::-webkit-scrollbar': { display: 'none' }, + }, + hideScrollY: { + msOverflowStyle: 'none', + scrollbarWidth: 'none', + overflowY: 'auto', + '&::-webkit-scrollbar': { display: 'none' }, + }, + scrollbarStyles: (theme: Theme): CSSObject => ({ + scrollbarWidth: 'thin', + scrollbarColor: `${varAlpha(theme.vars.palette.text.disabledChannel, 0.4)} ${varAlpha(theme.vars.palette.text.disabledChannel, 0.08)}`, + }), + bgBlur, + maxLine, + bgGradient, + softStyles, + paperStyles, + textGradient, + filledStyles, + borderGradient, + menuItemStyles, +}; diff --git a/apps/frontend/src/theme/core/mixins/text.ts b/apps/frontend/src/theme/core/mixins/text.ts new file mode 100644 index 0000000000..8f91d7f4ec --- /dev/null +++ b/apps/frontend/src/theme/core/mixins/text.ts @@ -0,0 +1,119 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { remToPx } from 'minimal-shared/utils'; + +import { createTheme as getTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * Creates a text gradient effect by applying a linear gradient as the text color. + * + * @param color - The gradient color definition. + * @returns A CSSObject that applies the gradient as text color. + * + * @example + * ...theme.mixins.textGradient( `to right, ${theme.vars.palette.text.primary}, ${varAlpha(theme.vars.palette.text.primary, 0.2)}` ) + */ + +export function textGradient(color?: string): CSSObject { + return { + background: `linear-gradient(${color})`, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + textFillColor: 'transparent', + color: 'transparent', + }; +} + +// ---------------------------------------------------------------------- + +/** + * Creates a multi-line text truncation style with optional height calculation based on typography. + * + * @param line - The number of lines to clamp. + * @param persistent - (Optional) Typography properties to calculate fixed height (e.g., fontSize, lineHeight). + * @returns A CSS object with styles. + * + * @example + * // Simple multi-line clamp + * ...theme.mixins.maxLine({ line: 2 }) + * + * @example + * // Clamp with calculated height based on typography + * theme.mixins.maxLine({ + * line: 2, + * persistent: theme.typography.caption, + * }) + */ + +type MediaFontSize = { + [key: string]: { + fontSize: React.CSSProperties['fontSize']; + }; +}; + +export type MaxLineProps = { + line: number; + persistent?: Partial; +}; + +function getFontSize(fontSize: React.CSSProperties['fontSize']) { + return typeof fontSize === 'string' ? remToPx(fontSize) : fontSize; +} + +function getLineHeight(lineHeight: React.CSSProperties['lineHeight'], fontSize?: number) { + if (typeof lineHeight === 'string') { + return fontSize ? remToPx(lineHeight) / fontSize : 1; + } + + return lineHeight; +} + +function calculateHeight(fontSize: number, lineHeight: number, line: number): number { + return fontSize * lineHeight * line; +} + +export function maxLine({ line, persistent }: MaxLineProps): CSSObject { + const { + breakpoints: { keys, up }, + } = getTheme(); + + const baseStyles: CSSObject = { + overflow: 'hidden', + display: '-webkit-box', + textOverflow: 'ellipsis', + WebkitLineClamp: line, + WebkitBoxOrient: 'vertical', + }; + + if (!persistent) { + return baseStyles; + } + + const fontSizeBase = getFontSize(persistent.fontSize); + const lineHeight = getLineHeight(persistent.lineHeight, fontSizeBase); + + if (!lineHeight || !fontSizeBase) { + return baseStyles; + } + + const responsiveStyles = keys.reduce((acc, breakpoint) => { + const fontSize = getFontSize((persistent as MediaFontSize)[up(breakpoint)]?.fontSize); + + if (fontSize) { + acc[up(breakpoint)] = { + height: calculateHeight(fontSize, lineHeight, line), + }; + } + + return acc; + }, {} as CSSObject); + + return { + ...baseStyles, + height: calculateHeight(fontSizeBase, lineHeight, line), + ...responsiveStyles, + }; +} diff --git a/apps/frontend/src/theme/core/opacity.ts b/apps/frontend/src/theme/core/opacity.ts new file mode 100644 index 0000000000..6b391a9a80 --- /dev/null +++ b/apps/frontend/src/theme/core/opacity.ts @@ -0,0 +1,41 @@ +import type { Opacity } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type OpacityExtend = { + filled: { + commonHoverBg: number; + }; + outlined: { + border: number; + }; + soft: { + bg: number; + hoverBg: number; + commonBg: number; + commonHoverBg: number; + border: number; + }; +}; + +export const opacity: Partial & OpacityExtend = { + // system + switchTrack: 1, + switchTrackDisabled: 0.48, + inputPlaceholder: 1, + inputUnderline: 0.32, + // shape + filled: { + commonHoverBg: 0.72, + }, + outlined: { + border: 0.48, + }, + soft: { + bg: 0.16, + hoverBg: 0.32, + commonBg: 0.08, + commonHoverBg: 0.16, + border: 0.24, + }, +}; diff --git a/apps/frontend/src/theme/core/palette.ts b/apps/frontend/src/theme/core/palette.ts new file mode 100644 index 0000000000..43a9eac65c --- /dev/null +++ b/apps/frontend/src/theme/core/palette.ts @@ -0,0 +1,180 @@ +import type { + TypeAction, + PaletteColor, + ColorSystemOptions, + PaletteColorChannel, +} from '@mui/material/styles'; +import type { SchemesRecord } from '../types'; + +import { varAlpha, createPaletteChannel } from 'minimal-shared/utils'; + +import { opacity } from './opacity'; +import { themeConfig } from '../theme-config'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../extend-theme-types.d.ts} + */ + +// Keys for core palette colors +export type PaletteColorKey = 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; +export type CommonColorsKeys = 'black' | 'white'; + +// Palette color without channels +export type PaletteColorNoChannels = Omit; + +// Palette color with channels +export type PaletteColorWithChannels = PaletteColor & PaletteColorChannel; + +// Extended palette color shades +export type PaletteColorExtend = { + lighter: string; + darker: string; + lighterChannel: string; + darkerChannel: string; +}; + +// Extended common colors +export type CommonColorsExtend = { + whiteChannel: string; + blackChannel: string; +}; + +// Extended text colors +export type TypeTextExtend = { + disabledChannel: string; +}; + +// Extended background colors +export type TypeBackgroundExtend = { + neutral: string; + neutralChannel: string; +}; + +// Extended grey colors +export type GreyExtend = { + '50Channel': string; + '100Channel': string; + '200Channel': string; + '300Channel': string; + '400Channel': string; + '500Channel': string; + '600Channel': string; + '700Channel': string; + '800Channel': string; + '900Channel': string; +}; + +// Extended palette +export type PaletteExtend = { + shared: { + inputOutlined: string; + inputUnderline: string; + paperOutlined: string; + buttonOutlined: string; + }; +}; + +/** + * ➤ + * ➤ ➤ Core palette (primary, secondary, info, success, warning, error, common, grey) + * ➤ + */ +export const primary = createPaletteChannel(themeConfig.palette.primary); +export const secondary = createPaletteChannel(themeConfig.palette.secondary); +export const info = createPaletteChannel(themeConfig.palette.info); +export const success = createPaletteChannel(themeConfig.palette.success); +export const warning = createPaletteChannel(themeConfig.palette.warning); +export const error = createPaletteChannel(themeConfig.palette.error); +export const common = createPaletteChannel(themeConfig.palette.common); +export const grey = createPaletteChannel(themeConfig.palette.grey); + +/** + * ➤ + * ➤ ➤ Text, background, action + * ➤ + */ +export const text = { + light: createPaletteChannel({ primary: grey[800], secondary: grey[600], disabled: grey[500] }), + dark: createPaletteChannel({ primary: '#FFFFFF', secondary: grey[500], disabled: grey[600] }), +}; + +export const background = { + light: createPaletteChannel({ paper: '#FFFFFF', default: '#FFFFFF', neutral: grey[200] }), + dark: createPaletteChannel({ paper: grey[800], default: grey[900], neutral: '#28323D' }), +}; + +export const action = (mode: 'light' | 'dark'): Partial => ({ + active: mode === 'light' ? grey[600] : grey[500], + hover: varAlpha(grey['500Channel'], 0.08), + selected: varAlpha(grey['500Channel'], 0.16), + focus: varAlpha(grey['500Channel'], 0.24), + disabled: varAlpha(grey['500Channel'], 0.8), + disabledBackground: varAlpha(grey['500Channel'], 0.24), + hoverOpacity: 0.08, + selectedOpacity: 0.08, + focusOpacity: 0.12, + activatedOpacity: 0.12, + disabledOpacity: 0.48, +}); + +/** + * ➤ + * ➤ ➤ Extended palette + * ➤ + */ +export const extendPalette: PaletteExtend = { + shared: { + inputUnderline: varAlpha(grey['500Channel'], opacity.inputUnderline), + inputOutlined: varAlpha(grey['500Channel'], 0.2), + paperOutlined: varAlpha(grey['500Channel'], 0.16), + buttonOutlined: varAlpha(grey['500Channel'], 0.32), + }, +}; + +/** + * ➤ + * ➤ ➤ Base configuration + * ➤ + */ +const basePalette: ColorSystemOptions['palette'] = { + primary, + secondary, + info, + success, + warning, + error, + common, + grey, + divider: varAlpha(grey['500Channel'], 0.2), + TableCell: { border: varAlpha(grey['500Channel'], 0.2) }, + ...extendPalette, +}; + +/* ********************************************************************** + * 📦 Final + * **********************************************************************/ +export const palette: SchemesRecord = { + light: { + ...basePalette, + text: text.light, + background: background.light, + action: action('light'), + }, + dark: { + ...basePalette, + text: text.dark, + background: background.dark, + action: action('dark'), + }, +}; + +export const colorKeys: { + palette: PaletteColorKey[]; + common: CommonColorsKeys[]; +} = { + palette: ['primary', 'secondary', 'info', 'success', 'warning', 'error'], + common: ['black', 'white'], +}; diff --git a/apps/frontend/src/theme/core/shadows.ts b/apps/frontend/src/theme/core/shadows.ts new file mode 100644 index 0000000000..45b4abfd96 --- /dev/null +++ b/apps/frontend/src/theme/core/shadows.ts @@ -0,0 +1,31 @@ +import type { Shadows } from '@mui/material/styles'; +import type { SchemesRecord } from '../types'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { createTheme } from '@mui/material/styles'; + +import { grey, common } from './palette'; + +// ---------------------------------------------------------------------- + +function updateShadowColor(shadow: string, colorChannel: string): string { + return shadow.replace(/rgba\(\d+,\d+,\d+,(.*?)\)/g, (_, alpha) => + varAlpha(colorChannel, parseFloat(alpha)) + ); +} + +function createShadows(colorChannel: string): Shadows { + // Get default MUI shadows + const { shadows: defaultShadows } = createTheme(); + + return defaultShadows.map((shadow) => updateShadowColor(shadow, colorChannel)) as Shadows; +} + +/* ********************************************************************** + * 📦 Final + * **********************************************************************/ +export const shadows: SchemesRecord = { + light: createShadows(grey['500Channel']), + dark: createShadows(common.blackChannel), +}; diff --git a/apps/frontend/src/theme/core/typography.ts b/apps/frontend/src/theme/core/typography.ts new file mode 100644 index 0000000000..3643f81cf3 --- /dev/null +++ b/apps/frontend/src/theme/core/typography.ts @@ -0,0 +1,148 @@ +import type { Breakpoint, TypographyVariantsOptions } from '@mui/material/styles'; + +import { pxToRem, setFont } from 'minimal-shared/utils'; + +import { createTheme } from '@mui/material/styles'; + +import { themeConfig } from '../theme-config'; + +// ---------------------------------------------------------------------- + +/** + * TypeScript extension for MUI theme augmentation. + * @to {@link file://./../extend-theme-types.d.ts} + */ + +export type TypographyVariantsExtend = { + fontWeightSemiBold: React.CSSProperties['fontWeight']; + fontWeightExtraBold: React.CSSProperties['fontWeight']; + fontSecondaryFamily: React.CSSProperties['fontFamily']; +}; + +/** + * Generates responsive font styles for given breakpoints + * @param sizes - Object mapping breakpoints to font sizes in pixels + * @returns CSS media query styles for responsive font sizes + */ +type FontSizesInput = Partial>; +type FontSizesResult = Record; + +function responsiveFontSizes(sizes: FontSizesInput): FontSizesResult { + const { + breakpoints: { keys, up }, + } = createTheme(); + + return keys.reduce((styles, breakpoint) => { + const size = sizes[breakpoint]; + + if (size !== undefined && size >= 0) { + styles[up(breakpoint)] = { + fontSize: pxToRem(size), + }; + } + + return styles; + }, {} as FontSizesResult); +} + +// ---------------------------------------------------------------------- + +const primaryFont = setFont(themeConfig.fontFamily.primary); +const secondaryFont = setFont(themeConfig.fontFamily.secondary); + +const baseTypography: TypographyVariantsOptions = { + fontFamily: primaryFont, + fontSecondaryFamily: secondaryFont, + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightSemiBold: 600, + fontWeightBold: 700, + fontWeightExtraBold: 800, +}; + +/* ********************************************************************** + * 📦 Final + * **********************************************************************/ +/** + * Line height is set as a unitless ratio: 22 / 14 ≈ 1.57 + * - 22px is the desired visual line height + * - 14px is the font size + * This keeps the line height scalable and responsive. + */ +export const typography: TypographyVariantsOptions = { + ...baseTypography, + h1: { + fontFamily: secondaryFont, + fontWeight: baseTypography.fontWeightExtraBold, + lineHeight: 80 / 64, + fontSize: pxToRem(40), + ...responsiveFontSizes({ sm: 52, md: 58, lg: 64 }), + }, + h2: { + fontFamily: secondaryFont, + fontWeight: baseTypography.fontWeightExtraBold, + lineHeight: 64 / 48, + fontSize: pxToRem(32), + ...responsiveFontSizes({ sm: 40, md: 44, lg: 48 }), + }, + h3: { + fontFamily: secondaryFont, + fontWeight: baseTypography.fontWeightBold, + lineHeight: 1.5, + fontSize: pxToRem(24), + ...responsiveFontSizes({ sm: 26, md: 30, lg: 32 }), + }, + h4: { + fontWeight: baseTypography.fontWeightBold, + lineHeight: 1.5, + fontSize: pxToRem(20), + ...responsiveFontSizes({ md: 24 }), + }, + h5: { + fontWeight: baseTypography.fontWeightBold, + lineHeight: 1.5, + fontSize: pxToRem(18), + ...responsiveFontSizes({ sm: 19 }), + }, + h6: { + fontWeight: baseTypography.fontWeightSemiBold, + lineHeight: 28 / 18, + fontSize: pxToRem(17), + ...responsiveFontSizes({ sm: 18 }), + }, + subtitle1: { + fontWeight: baseTypography.fontWeightSemiBold, + lineHeight: 1.5, + fontSize: pxToRem(16), + }, + subtitle2: { + fontWeight: baseTypography.fontWeightSemiBold, + lineHeight: 22 / 14, + fontSize: pxToRem(14), + }, + body1: { + lineHeight: 1.5, + fontSize: pxToRem(16), + }, + body2: { + lineHeight: 22 / 14, + fontSize: pxToRem(14), + }, + caption: { + lineHeight: 1.5, + fontSize: pxToRem(12), + }, + overline: { + fontWeight: baseTypography.fontWeightBold, + lineHeight: 1.5, + fontSize: pxToRem(12), + textTransform: 'uppercase', + }, + button: { + fontWeight: baseTypography.fontWeightBold, + lineHeight: 24 / 14, + fontSize: pxToRem(14), + textTransform: 'unset', + }, +}; diff --git a/apps/frontend/src/theme/create-classes.ts b/apps/frontend/src/theme/create-classes.ts new file mode 100644 index 0000000000..977771a22c --- /dev/null +++ b/apps/frontend/src/theme/create-classes.ts @@ -0,0 +1,7 @@ +import { themeConfig } from './theme-config'; + +// ---------------------------------------------------------------------- + +export function createClasses(className: string): string { + return `${themeConfig.classesPrefix}__${className}`; +} diff --git a/apps/frontend/src/theme/create-theme.ts b/apps/frontend/src/theme/create-theme.ts new file mode 100644 index 0000000000..ec28db9f62 --- /dev/null +++ b/apps/frontend/src/theme/create-theme.ts @@ -0,0 +1,40 @@ +import type { Theme } from '@mui/material/styles'; +import type { ThemeOptions } from './types'; + +import { createTheme as createMuiTheme } from '@mui/material/styles'; + +import { components } from './core/components'; +import { customShadows } from './core/custom-shadows'; +import { mixins } from './core/mixins'; +import { opacity } from './core/opacity'; +import { palette } from './core/palette'; +import { shadows } from './core/shadows'; +import { typography } from './core/typography'; +import { themeConfig } from './theme-config'; + +export const baseTheme: ThemeOptions = { + colorSchemes: { + light: { + palette: palette.light, + shadows: shadows.light, + customShadows: customShadows.light, + opacity, + }, + dark: { + palette: palette.dark, + shadows: shadows.dark, + customShadows: customShadows.dark, + opacity, + }, + }, + mixins, + components, + typography, + shape: { borderRadius: 8 }, + direction: themeConfig.direction, + cssVariables: themeConfig.cssVariables, +}; + +export function createTheme(themeOverrides: ThemeOptions = {}): Theme { + return createMuiTheme(baseTheme, themeOverrides); +} diff --git a/apps/frontend/src/theme/extend-theme-types.d.ts b/apps/frontend/src/theme/extend-theme-types.d.ts new file mode 100644 index 0000000000..43fd3588cb --- /dev/null +++ b/apps/frontend/src/theme/extend-theme-types.d.ts @@ -0,0 +1,203 @@ +import type {} from '@mui/lab/themeAugmentation'; +import type {} from '@mui/x-tree-view/themeAugmentation'; +import type {} from '@mui/x-data-grid/themeAugmentation'; +import type {} from '@mui/x-date-pickers/themeAugmentation'; +import type {} from '@mui/material/themeCssVarsAugmentation'; +import type { DeepPartial } from './types'; +import type { MixinsExtend } from './core/mixins'; +import type { OpacityExtend } from './core/opacity'; +import type { CustomShadows } from './core/custom-shadows'; +import type { RatingExtendSize } from './core/components/rating'; +import type { TypographyVariantsExtend } from './core/typography'; +import type { SliderExtendColor } from './core/components/slider'; +import type { BadgeExtendVariant } from './core/components/badge'; +import type { TabsExtendIndicatorColor } from './core/components/tabs'; +import type { IconButtonExtendColor } from './core/components/button-icon'; +import type { ChipExtendColor, ChipExtendVariant } from './core/components/chip'; +import type { FabExtendColor, FabExtendVariant } from './core/components/button-fab'; +import type { AvatarExtendColor, AvatarGroupExtendVariant } from './core/components/avatar'; +import type { PaginationExtendColor, PaginationExtendVariant } from './core/components/pagination'; +import type { + ButtonGroupExtendColor, + ButtonGroupExtendVariant, +} from './core/components/button-group'; +import type { + ButtonExtendSize, + ButtonExtendColor, + ButtonExtendVariant, +} from './core/components/button'; +import type { + GreyExtend, + PaletteExtend, + TypeTextExtend, + CommonColorsExtend, + PaletteColorExtend, + TypeBackgroundExtend, +} from './core/palette'; + +// ---------------------------------------------------------------------- + +/* ********************************************************************** + * 🧬 Extend: Core (palette, typography, shadows, mixins...) + * **********************************************************************/ +declare module '@mui/material/styles' { + /** + * ➤➤ Palette (https://mui.com/customization/palette/) + * @from {@link file://./core/palette.ts} + */ + // primary, secondary, info, success, warning, error + interface PaletteColor extends PaletteColorExtend {} + interface SimplePaletteColorOptions extends Partial {} + + // text, background, common, grey + interface Color extends GreyExtend {} + interface TypeText extends TypeTextExtend {} + interface CommonColors extends CommonColorsExtend {} + interface TypeBackground extends TypeBackgroundExtend {} + + // extend palette + interface Palette extends PaletteExtend {} + interface PaletteOptions extends DeepPartial {} + + /** + * ➤➤ Typography (https://mui.com/customization/typography/) + * @from {@link file://./core/typography.ts} + */ + interface TypographyVariants extends TypographyVariantsExtend {} + interface TypographyVariantsOptions extends Partial {} + + /** + * ➤➤ Mixins + * @from {@link file://./core/mixins.ts} + */ + interface Mixins extends MixinsExtend {} + interface MixinsOptions extends Partial {} + + /** + * ➤➤ Opacity + * @from {@link file://./core/opacity.ts} + */ + interface Opacity extends OpacityExtend {} + + /** + * Register the new variant in the `Theme` interface. + * + * ➤➤ Custom shadows + * @from {@link file://./core/custom-shadows.ts} + * + */ + interface Theme { + customShadows: CustomShadows; + } + interface ThemeOptions { + customShadows?: Partial; + } + interface ThemeVars { + customShadows: CustomShadows; + } +} + +/* ********************************************************************** + * 🧬 Extend: Components + * **********************************************************************/ + +/** + * ➤➤ Avatar, AvatarGroup (https://mui.com/components/avatars/) + * @from {@link file://./core/components/avatar.tsx} + */ +declare module '@mui/material/Avatar' { + interface AvatarOwnProps extends AvatarExtendColor {} +} +declare module '@mui/material/AvatarGroup' { + interface AvatarGroupPropsVariantOverrides extends AvatarGroupExtendVariant {} +} + +/** + * ➤➤ Badge (https://mui.com/components/badges/) + * @from {@link file://./core/components/badge.tsx} + */ +declare module '@mui/material/Badge' { + interface BadgePropsVariantOverrides extends BadgeExtendVariant {} +} + +/** + * ➤➤ Button (https://mui.com/components/buttons/) + * @from {@link file://./core/components/button.tsx} + */ +declare module '@mui/material/Button' { + interface ButtonPropsVariantOverrides extends ButtonExtendVariant {} + interface ButtonPropsColorOverrides extends ButtonExtendColor {} + interface ButtonPropsSizeOverrides extends ButtonExtendSize {} +} + +/** + * ➤➤ IconButton (https://mui.com/components/buttons/#icon-button) + * @from {@link file://./core/components/button-icon.tsx} + */ +declare module '@mui/material/IconButton' { + interface IconButtonPropsColorOverrides extends IconButtonExtendColor {} +} + +/** + * ➤➤ ButtonGroup (https://mui.com/components/button-group/) + * @from {@link file://./core/components/button-group.tsx} + */ +declare module '@mui/material/ButtonGroup' { + interface ButtonGroupPropsVariantOverrides extends ButtonGroupExtendVariant {} + interface ButtonGroupPropsColorOverrides extends ButtonGroupExtendColor {} +} + +/** + * ➤➤ Fab (https://mui.com/components/floating-action-button/) + * @from {@link file://./core/components/button-fab.tsx} + */ +declare module '@mui/material/Fab' { + interface FabPropsVariantOverrides extends FabExtendVariant {} + interface FabPropsColorOverrides extends FabExtendColor {} +} + +/** + * ➤➤ Chip (https://mui.com/components/chips/) + * @from {@link file://./core/components/chip.tsx} + */ +declare module '@mui/material/Chip' { + interface ChipPropsVariantOverrides extends ChipExtendVariant {} + interface ChipPropsColorOverrides extends ChipExtendColor {} +} + +/** + * ➤➤ Pagination (https://mui.com/components/pagination/) + * @from {@link file://./core/components/pagination.tsx} + */ +declare module '@mui/material/Pagination' { + interface PaginationPropsVariantOverrides extends PaginationExtendVariant {} + interface PaginationPropsColorOverrides extends PaginationExtendColor {} +} +declare module '@mui/material/PaginationItem' { + interface PaginationItemPropsVariantOverrides extends PaginationExtendVariant {} + interface PaginationItemPropsColorOverrides extends PaginationExtendColor {} +} + +/** + * ➤➤ Slider (https://mui.com/components/slider/) + * @from {@link file://./core/components/slider.tsx} + */ +declare module '@mui/material/Slider' { + interface SliderPropsColorOverrides extends SliderExtendColor {} +} + +/** + * ➤➤ Rating (https://mui.com/components/rating/) + * @from {@link file://./core/components/rating.tsx} + */ +declare module '@mui/material/Rating' { + interface RatingPropsSizeOverrides extends RatingExtendSize {} +} + +/** + * ➤➤ Tabs (https://mui.com/components/tabs/) + * @from {@link file://./core/components/tabs.tsx} + */ +declare module '@mui/material/Tabs' { + interface TabsPropsIndicatorColorOverrides extends TabsExtendIndicatorColor {} +} diff --git a/apps/frontend/src/theme/index.ts b/apps/frontend/src/theme/index.ts new file mode 100644 index 0000000000..9cca0d75ab --- /dev/null +++ b/apps/frontend/src/theme/index.ts @@ -0,0 +1,7 @@ +export * from './core'; + +export * from './theme-config'; + +export * from './theme-provider'; + +export type * from './types'; diff --git a/apps/frontend/src/theme/theme-config.ts b/apps/frontend/src/theme/theme-config.ts new file mode 100644 index 0000000000..dc4aaea890 --- /dev/null +++ b/apps/frontend/src/theme/theme-config.ts @@ -0,0 +1,113 @@ +import type { Theme, Direction, CommonColors, ThemeProviderProps } from '@mui/material/styles'; +import type { ThemeCssVariables } from './types'; +import type { PaletteColorKey, PaletteColorNoChannels } from './core/palette'; + +// ---------------------------------------------------------------------- + +export type ThemeConfig = { + direction: Direction; + classesPrefix: string; + cssVariables: ThemeCssVariables; + defaultMode: ThemeProviderProps['defaultMode']; + modeStorageKey: ThemeProviderProps['modeStorageKey']; + fontFamily: Record<'primary' | 'secondary', string>; + palette: Record & { + common: Pick; + grey: { + [K in 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 as `${K}`]: string; + }; + }; +}; + +export const themeConfig: ThemeConfig = { + /** ************************************** + * Base + *************************************** */ + defaultMode: 'light', + modeStorageKey: 'theme-mode', + direction: 'ltr', + classesPrefix: 'minimal', + /** ************************************** + * Css variables + *************************************** */ + cssVariables: { + cssVarPrefix: '', + colorSchemeSelector: 'data-color-scheme', + }, + /** ************************************** + * Typography + *************************************** */ + fontFamily: { + primary: 'Public Sans Variable', + secondary: 'DM Sans Variable', + }, + /** ************************************** + * Palette + *************************************** */ + palette: { + primary: { + lighter: '#C8FAD6', + light: '#5BE49B', + main: '#00A76F', + dark: '#007867', + darker: '#004B50', + contrastText: '#FFFFFF', + }, + secondary: { + lighter: '#EFD6FF', + light: '#C684FF', + main: '#8E33FF', + dark: '#5119B7', + darker: '#27097A', + contrastText: '#FFFFFF', + }, + info: { + lighter: '#CAFDF5', + light: '#61F3F3', + main: '#00B8D9', + dark: '#006C9C', + darker: '#003768', + contrastText: '#FFFFFF', + }, + success: { + lighter: '#D3FCD2', + light: '#77ED8B', + main: '#22C55E', + dark: '#118D57', + darker: '#065E49', + contrastText: '#ffffff', + }, + warning: { + lighter: '#FFF5CC', + light: '#FFD666', + main: '#FFAB00', + dark: '#B76E00', + darker: '#7A4100', + contrastText: '#1C252E', + }, + error: { + lighter: '#FFE9D5', + light: '#FFAC82', + main: '#FF5630', + dark: '#B71D18', + darker: '#7A0916', + contrastText: '#FFFFFF', + }, + grey: { + 50: '#FCFDFD', + 100: '#F9FAFB', + 200: '#F4F6F8', + 300: '#DFE3E8', + 400: '#C4CDD5', + 500: '#919EAB', + 600: '#637381', + 700: '#454F5B', + 800: '#1C252E', + 900: '#141A21', + }, + common: { + black: '#000000', + white: '#FFFFFF', + }, + }, +}; diff --git a/apps/frontend/src/theme/theme-overrides.ts b/apps/frontend/src/theme/theme-overrides.ts new file mode 100644 index 0000000000..7db7176965 --- /dev/null +++ b/apps/frontend/src/theme/theme-overrides.ts @@ -0,0 +1,36 @@ +'use client'; + +import type { ThemeOptions } from './types'; + +import { createPaletteChannel } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +export const themeOverrides: ThemeOptions = { + colorSchemes: { + light: { + palette: { + primary: createPaletteChannel({ + lighter: '#E4DCFD', + light: '#A996F8', + main: '#6950E8', + dark: '#3828A7', + darker: '#180F6F', + contrastText: '#FFFFFF', + }), + }, + }, + dark: { + palette: { + primary: createPaletteChannel({ + lighter: '#E4DCFD', + light: '#A996F8', + main: '#6950E8', + dark: '#3828A7', + darker: '#180F6F', + contrastText: '#FFFFFF', + }), + }, + }, + }, +}; diff --git a/apps/frontend/src/theme/theme-provider.tsx b/apps/frontend/src/theme/theme-provider.tsx new file mode 100644 index 0000000000..f0e3e1409b --- /dev/null +++ b/apps/frontend/src/theme/theme-provider.tsx @@ -0,0 +1,24 @@ +import type { Theme, ThemeProviderProps as MuiThemeProviderProps } from '@mui/material/styles'; +import type {} from './extend-theme-types'; +import type { ThemeOptions } from './types'; + +import CssBaseline from '@mui/material/CssBaseline'; +import { ThemeProvider as ThemeVarsProvider } from '@mui/material/styles'; +import { useMemo } from 'react'; + +import { createTheme } from './create-theme'; + +export type ThemeProviderProps = Partial> & { + themeOverrides?: ThemeOptions; +}; + +export function ThemeProvider({ themeOverrides, children, ...other }: ThemeProviderProps) { + const theme = useMemo(() => createTheme(themeOverrides), [themeOverrides]); + + return ( + + + {children} + + ); +} diff --git a/apps/frontend/src/theme/types.ts b/apps/frontend/src/theme/types.ts new file mode 100644 index 0000000000..3f55bf6660 --- /dev/null +++ b/apps/frontend/src/theme/types.ts @@ -0,0 +1,52 @@ +import type { + Theme, + Shadows, + Components, + ColorSystemOptions, + CssVarsThemeOptions, + SupportedColorScheme, + ThemeOptions as MuiThemeOptions, +} from '@mui/material/styles'; +import type { CustomShadows } from './core/custom-shadows'; + +// ---------------------------------------------------------------------- + +/** + * Theme options + * Extended type that includes additional properties for color schemes and CSS variables. + * + * @see https://github.com/mui/material-ui/blob/master/packages/mui-material/src/styles/createTheme.ts + */ + +export type ThemeColorScheme = SupportedColorScheme; +export type ThemeCssVariables = Pick< + CssVarsThemeOptions, + | 'cssVarPrefix' + | 'rootSelector' + | 'colorSchemeSelector' + | 'disableCssColorScheme' + | 'shouldSkipGeneratingVar' +>; + +type ColorSchemeOptionsExtended = ColorSystemOptions & { + shadows?: Partial; + customShadows?: Partial; +}; + +export type SchemesRecord = Partial>; + +export type ThemeOptions = Omit & + Pick & { + colorSchemes?: SchemesRecord; + cssVariables?: ThemeCssVariables; + components?: Components; + }; + +// ---------------------------------------------------------------------- + +/** + * DeepPartial utility type that recursively makes all properties of T optional. + * This is useful for partial configurations and merging deeply nested objects. + * Supports objects, arrays, and primitive types. + */ +export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000000..b988cf3413 --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "types": ["vite/client", "@testing-library/jest-dom"], + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts new file mode 100644 index 0000000000..bdc80e24e9 --- /dev/null +++ b/apps/frontend/vite.config.ts @@ -0,0 +1,23 @@ +/// +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + build: { + rollupOptions: { + output: { + manualChunks: { + 'vendor-chart': ['highcharts', 'highcharts-react-official'], + 'vendor-data': ['@orpc/client', '@orpc/openapi-client', '@reduxjs/toolkit', 'redux-saga'], + }, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: './src/test/setup.ts', + }, +}); diff --git a/back-end-challenge-v1.md b/back-end-challenge-v1.md deleted file mode 100644 index 46d4e8d19d..0000000000 --- a/back-end-challenge-v1.md +++ /dev/null @@ -1,79 +0,0 @@ -# Dynamox Back-end Developer Challenge - -[< back to README](./README.md) - -Dynamox Back-end|Data Science development team presents you with the following challenge: - -**Using Python develop a sever side solution that demonstrate your expertise in back-end development.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Challenge 1: Signal Processing API🌐 - -### Overview -In this challenge, you will create a modern and performant api that showcases your expertise in backend development, database modeling and networking. - - -### Functional Requirements and User Stories - -The server side application is the backbone of many modern applications, low latency, high diponibility, safety and security are crucial, your implementation must follow best practices in those areas. Your back-end application is a time-series processor, it holds time-series data in a persistant storage, it can receive raw data series, and retrieve metrics about the time series, and delete the same data. - -1 - User Stories -1. [ ] As a user, I want to be able to store a raw data series. -2. [ ] As a user, I want to be able to retrieve metrics about the time series. -3. [ ] As a user, I want to be able to delete a time series i've sent to the server. -4. [ ] As a user, I want to be able to retrieve the number of time series i've stored in the server. -5. [ ] As a user, I want to be able to retrieve the a full time series, i've stored. - -2 - Technical Requirements -1. [ ] Use Python -2. [ ] Use a REST-API framework (ex.: FastAPI) -3. [ ] The latency between client and the server side must be below 350ms in all requests -4. [ ] Use a database to store the time series data -5. [ ] Ensure correct business logic and behavior with automated unit tests (ex.: pytest); - -3 - Bonus -1. [ ] Deploy your application to a cloud provider and provide the api url. -2. [ ] Implement a functionality that gives me a future prediction of the time series data. -3. [ ] Add load balancer to the application. -4. [ ] Add load tests to the application. - - -## Evaluation Criteria - -Each one of the items above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -2. [ ] Back-end code successfully integrated with a persistant storage. -3. [ ] Stories were implemented according to the functional requirements. -4. [ ] Problem-solving skills and ability to handle ambiguity. -5. [ ] Code quality, readability, and maintainability. -6. [ ] Code is well-organized and documented. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/developer-challenges) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* Can I use IA to complete the challenge? - **Yes, however have in mind you will need to explain your decision and code** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** diff --git a/back-end-challenge-v2.md b/back-end-challenge-v2.md deleted file mode 100644 index c929a6b16a..0000000000 --- a/back-end-challenge-v2.md +++ /dev/null @@ -1,82 +0,0 @@ -# Dynamox Back-end Developer Challenge - -[< back to README](./README.md) - -Dynamox Back-end|Data Science development team presents you with the following challenge: - -**Using Node.js develop a server side solution that demonstrates your expertise in back-end development.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Challenge 1: Signal Processing API🌐 - -### Overview - -In this challenge, you will create a modern and performant api that showcases your expertise in backend development, database modeling and networking. - -### Functional Requirements and User Stories - -The server side application is the backbone of many modern applications, low latency, high diponibility, safety and security are crucial, your implementation must follow best practices in those areas. Your back-end application is a time-series processor, it holds time-series data in a persistant storage, it can receive raw data series, and retrieve metrics about the time series, and delete the same data. - -1 - User Stories - -1. [ ] As a user, I want to be able to store a raw data series. -2. [ ] As a user, I want to be able to retrieve metrics about the time series. -3. [ ] As a user, I want to be able to delete a time series i've sent to the server. -4. [ ] As a user, I want to be able to retrieve the number of time series i've stored in the server. -5. [ ] As a user, I want to be able to retrieve the a full time series, i've stored. - -2 - Technical Requirements - -1. [ ] Use Node.js -2. [ ] Use a REST-API framework (ex.: Express) -3. [ ] The latency between client and the server side must be below 350ms in all requests -4. [ ] Use a database to store the time series data (MongoDB preferred) -5. [ ] Ensure correct business logic and behavior with automated unit tests (ex.: Jest); - -3 - Bonus - -1. [ ] Deploy your application to a cloud provider and provide the api url. -2. [ ] Publish stored time series data to a Kafka topic. The Kafka environment must be configured via Docker and orchestrated through a `Makefile`. All commands necessary to start the Kafka infrastructure and run the project must be documented in the project's `README.md`. -3. [ ] Add load balancer to the application. -4. [ ] Add load tests to the application. - -## Evaluation Criteria - -Each one of the items above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: - -1. [ ] Anyone should be able to follow the instructions and run the application. -2. [ ] Back-end code successfully integrated with a persistant storage. -3. [ ] Stories were implemented according to the functional requirements. -4. [ ] Problem-solving skills and ability to handle ambiguity. -5. [ ] Code quality, readability, and maintainability. -6. [ ] Code is well-organized and documented. - -## Ready to Begin the Challenges? - -- Fork this repository to your own Github account. -- Create a new branch using your first name and last name. For example: `caroline-oliveira`. -- After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/developer-challenges) pointing to the main branch. -- We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -- Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -- Can I use IA to complete the challenge? - **Yes, however have in mind you will need to explain your decision and code** - -- If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000000..4722c1b7a7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,774 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "dynamox-challenge", + "devDependencies": { + "typescript": "catalog:", + }, + }, + "apps/backend": { + "name": "backend", + "version": "1.0.0", + "dependencies": { + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "@orpc/zod": "catalog:", + "@repo/contracts": "workspace:*", + "zod": "catalog:", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "catalog:", + }, + }, + "apps/frontend": { + "name": "frontend", + "version": "1.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/public-sans": "^5.2.7", + "@iconify/react": "^6.0.2", + "@mui/material": "^7.3.2", + "@mui/x-date-pickers": "^8.12.0", + "@orpc/client": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi-client": "catalog:", + "@reduxjs/toolkit": "^2.12.0", + "@repo/contracts": "workspace:*", + "dayjs": "^1.11.18", + "es-toolkit": "^1.39.10", + "highcharts": "^12.4.0", + "highcharts-react-official": "^3.2.2", + "minimal-shared": "^1.1.0", + "react": "catalog:", + "react-dom": "catalog:", + "react-redux": "^9.2.0", + "react-router": "^7.9.1", + "redux-saga": "^1.5.0", + "simplebar-react": "^3.3.2", + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.15", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "jsdom": "^26.1.0", + "typescript": "catalog:", + "vite": "^7.1.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:", + }, + }, + "packages/contracts": { + "name": "@repo/contracts", + "version": "1.0.0", + "dependencies": { + "@orpc/contract": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "typescript": "catalog:", + }, + }, + }, + "catalog": { + "@orpc/client": "1.14.8", + "@orpc/contract": "1.14.8", + "@orpc/openapi": "1.14.8", + "@orpc/openapi-client": "1.14.8", + "@orpc/react-query": "1.14.8", + "@orpc/server": "1.14.8", + "@orpc/zod": "1.14.8", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "typescript": "^5.9.2", + "vitest": "^3.2.4", + "zod": "^4.1.11", + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/styled": ["@emotion/styled@11.14.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", "@types/react": "*", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw=="], + + "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.2.8", "", {}, "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g=="], + + "@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="], + + "@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@7.3.11", "", {}, "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw=="], + + "@mui/material": ["@mui/material@7.3.11", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.11", "@mui/system": "^7.3.11", "@mui/types": "^7.4.12", "@mui/utils": "^7.3.11", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.2.3", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^7.3.11", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw=="], + + "@mui/private-theming": ["@mui/private-theming@7.3.11", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "^7.3.11", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA=="], + + "@mui/styled-engine": ["@mui/styled-engine@7.3.10", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng=="], + + "@mui/system": ["@mui/system@7.3.11", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/private-theming": "^7.3.11", "@mui/styled-engine": "^7.3.10", "@mui/types": "^7.4.12", "@mui/utils": "^7.3.11", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g=="], + + "@mui/types": ["@mui/types@7.4.12", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w=="], + + "@mui/utils": ["@mui/utils@7.3.11", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/types": "^7.4.12", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.3" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g=="], + + "@mui/x-date-pickers": ["@mui/x-date-pickers@8.29.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@mui/utils": "^7.3.5", "@mui/x-internals": "8.29.2", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", "dayjs": "^1.10.7", "luxon": "^3.0.2", "moment": "^2.29.4", "moment-hijri": "^2.1.2 || ^3.0.0", "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "date-fns", "date-fns-jalali", "dayjs", "luxon", "moment", "moment-hijri", "moment-jalaali"] }, "sha512-02efypE9TqoBEMAJ/kzS5al1suo9E2iStuItZnBlQ8/DdLtKHpeeXgK4KNabCg+e4SeF+XuBbu06vL7sNnbXvg=="], + + "@mui/x-internals": ["@mui/x-internals@8.29.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@mui/utils": "^7.3.5", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-TLILyHia5NHh3MGErFDh0bXZ4V6iS45hdStofsV5wklF6dpgZjduo65oJB0h81mI5mexNlhHANEVfw0KV6BxBg=="], + + "@orpc/client": ["@orpc/client@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "@orpc/standard-server-fetch": "1.14.8", "@orpc/standard-server-peer": "1.14.8" } }, "sha512-Gu9pttl8BvOdU0skSwsYWWL31e+qpLXmHTHTy990vEbrWncVFulOtWJtwY/rV1nX7AfWnSyJZJwCFZvMcFnQgA=="], + + "@orpc/contract": ["@orpc/contract@1.14.8", "", { "dependencies": { "@orpc/client": "1.14.8", "@orpc/shared": "1.14.8", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-Z+CnA6z6mBUUgHjfdMk82QvOocADgVxz5ZoS13BmqLsWuEO8dFm9eVTHRJ0NJDJ2KEHApR+1vshA0bXBQRuClw=="], + + "@orpc/interop": ["@orpc/interop@1.14.8", "", {}, "sha512-DgLjqMVMUEqtDWVgd3L3rScoAmO5uHumpwaxcCmQygAgGrCMiJ8S48ePOgkVajpxt5eC25tybd2veY4hnKRtUQ=="], + + "@orpc/json-schema": ["@orpc/json-schema@1.14.8", "", { "dependencies": { "@orpc/contract": "1.14.8", "@orpc/interop": "1.14.8", "@orpc/openapi": "1.14.8", "@orpc/server": "1.14.8", "@orpc/shared": "1.14.8", "json-schema-typed": "^8.0.2" } }, "sha512-vUU95SiJe+AwRP/9y2xAKMhdYb86f6Jgf8GOqt4bpxhleS8ujpd9YHZXVkT9M7jNkL1Z2+ftykwmfvW2IdXs6A=="], + + "@orpc/openapi": ["@orpc/openapi@1.14.8", "", { "dependencies": { "@orpc/client": "1.14.8", "@orpc/contract": "1.14.8", "@orpc/interop": "1.14.8", "@orpc/openapi-client": "1.14.8", "@orpc/server": "1.14.8", "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-/kIScUqpy4oeDJt1LPhLsosLrnYj5lx1OkiYKg873UN1XQru4d/X4rzv3jJdKAOYvpqwcQje7fDfFIiQAaRBLg=="], + + "@orpc/openapi-client": ["@orpc/openapi-client@1.14.8", "", { "dependencies": { "@orpc/client": "1.14.8", "@orpc/contract": "1.14.8", "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8" } }, "sha512-d1eaL8H67Lz/OpNx02cWKu3w3zmqDwCCAZRVjerG4wC2MmbbBGliS1PYVPTvAeetAk81JnyD8i6w13Qf8SNpwA=="], + + "@orpc/server": ["@orpc/server@1.14.8", "", { "dependencies": { "@orpc/client": "1.14.8", "@orpc/contract": "1.14.8", "@orpc/interop": "1.14.8", "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "@orpc/standard-server-aws-lambda": "1.14.8", "@orpc/standard-server-fastify": "1.14.8", "@orpc/standard-server-fetch": "1.14.8", "@orpc/standard-server-node": "1.14.8", "@orpc/standard-server-peer": "1.14.8", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-Do6xBim+uMtjlEy+08BpGInN78SmH9zVL9CNOUqmo//RmkTkb7C8LaqiPz/Y4QKvDdVRo15ku0C4OQhfvzlkSg=="], + + "@orpc/shared": ["@orpc/shared@1.14.8", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-4fsmUMLx1qDYup2K3h9+heY+LfVXzdBa1bfhvbV7GHkdLrvyrVAMhiX4eAmKCog37vVvmpXf3sSUgrOB9R05ag=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8" } }, "sha512-v0iqvzJ6NzoVRYhk/GJl3/SqC124hSwcDOjPotZbILvx8XwfBlG4V/uhyaZ8baj+l5h79afrhia6Dq9QzFomEQ=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "@orpc/standard-server-fetch": "1.14.8", "@orpc/standard-server-node": "1.14.8" } }, "sha512-uX+x+cp8jGK276QOFM/mIBTLzyliruigpBhELUUiw9NoHbCBaD0fw2q4tHNyJk5ojpCIabhlBanT4BM3vZXhOQ=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "@orpc/standard-server-node": "1.14.8" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-rAyaTclXpk7jOGGqAqlll0GDGT72gORw3oV0h+mJopo1yrJtdIqbJTBuQLUQOBqe/R9DFQfvvvUEnHVmV9nAjw=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8" } }, "sha512-iuYb+x+zJzdhzLI15bBliQ0wGNJQUXOHiDCJF2EcvjCmEMXRM0dfEFP9UdV7aK4Fz13xxzba26vOnlwlDrj3qg=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8", "@orpc/standard-server-fetch": "1.14.8" } }, "sha512-4JqKQePERSJo1p5Hn+kKa2TweRnEXaPi6ssx4Tnj3Y5lWn96sy+9uCUcdEdvlD3ZgAoN1EULUM9d/N/BD2g93g=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.8", "", { "dependencies": { "@orpc/shared": "1.14.8", "@orpc/standard-server": "1.14.8" } }, "sha512-zWRV5eOoJR01pDya9H3xLZag5Bao5rfB60H+E+UlmfCC8TJANI8xl41sCWYu4WaYxQbvfom0T7TsTadLxMGszA=="], + + "@orpc/zod": ["@orpc/zod@1.14.8", "", { "dependencies": { "@orpc/json-schema": "1.14.8", "@orpc/openapi": "1.14.8", "@orpc/shared": "1.14.8", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.8", "@orpc/server": "1.14.8", "zod": ">=3.25.0" } }, "sha512-Gf7oywZziLst/MS0WOC4GHi51QoI399OQrPYqjSle2ks7nrmQp4e9JOZq1HKcNVZJ87NWXz24KA7mFwkS1LrJA=="], + + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + + "@redux-saga/core": ["@redux-saga/core@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@redux-saga/deferred": "^1.3.1", "@redux-saga/delay-p": "^1.3.1", "@redux-saga/is": "^1.2.1", "@redux-saga/symbols": "^1.2.1", "@redux-saga/types": "^1.4.0" } }, "sha512-oMWpQntvVikLhr+wZsvGDzkjW79HphBgEb6Hncz8OkM0azsT3uUnP1Her/W5QZhcDG+tWmjDhah8uDbPSRLs/Q=="], + + "@redux-saga/deferred": ["@redux-saga/deferred@1.3.1", "", {}, "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg=="], + + "@redux-saga/delay-p": ["@redux-saga/delay-p@1.3.1", "", { "dependencies": { "@redux-saga/symbols": "^1.2.1" } }, "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg=="], + + "@redux-saga/is": ["@redux-saga/is@1.2.1", "", { "dependencies": { "@redux-saga/symbols": "^1.2.1", "@redux-saga/types": "^1.3.1" } }, "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA=="], + + "@redux-saga/symbols": ["@redux-saga/symbols@1.2.1", "", {}, "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA=="], + + "@redux-saga/types": ["@redux-saga/types@1.4.0", "", {}, "sha512-fq3Vy0TRl0mEb+1CyTx5wPBY3jxDi7yD4GL1Kz4S6ud7/leGEgRq1NYJDIiVbHMRBJd5rU/BB0Z+akrXSR0DJg=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + + "@repo/contracts": ["@repo/contracts@workspace:packages/contracts"], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + + "backend": ["backend@workspace:apps/backend"], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.392", "", {}, "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + + "frontend": ["frontend@workspace:apps/frontend"], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "highcharts": ["highcharts@12.6.0", "", { "peerDependencies": { "jspdf": "^4.1.0", "svg2pdf.js": "^2.7.0" }, "optionalPeers": ["jspdf", "svg2pdf.js"] }, "sha512-zWwoXECOakJT2huBMqIZOjDNLLgyZFCcfZqTj2g6cvYOGxjF3nvooC+rIaF5aKiharluyGKNflfRNrzOAIjdRg=="], + + "highcharts-react-official": ["highcharts-react-official@3.2.3", "", { "peerDependencies": { "highcharts": ">=6.0.0", "react": ">=16.8.0" } }, "sha512-2gL8bVGe6Pf75tSe6IB5Ucd0nIOJX7ZrpttQBZVrjN2J9StdVZ12aOinXHIOFvlc6EzP8CbN13WS8NUq+9mptA=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "immer": ["immer@11.1.12", "", {}, "sha512-A3biLANycxnm4pvimEwQbzkI6JleXC3gyARYc6oMPBu2YhcHg3s9vdphogS/Hutd4oNpx7s/5U9zQ7m4PTP5xg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimal-shared": ["minimal-shared@1.1.6", "", { "dependencies": { "es-toolkit": "^1.46.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-GNzk/ohUh0GCC6q/E1rvupPPXkxl0IF9bMEGAmNC0BYuA79Ss22sfFkYn86Z1a7gMCmUyiqpFowCFuZb3N8TQQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-router": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-saga": ["redux-saga@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@redux-saga/core": "^1.5.0" } }, "sha512-IdnDBfgt/XuBhSwWMGwjtcOOQAmiqOc1nRGxNst2MWP9yLN0zOvaPpinm8rtKoL00kMZgczZFPNNQgy2hoBIBw=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simplebar-core": ["simplebar-core@1.3.2", "", { "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21" } }, "sha512-qKgTTuTqapjsFGkNhCjyPhysnbZGpQqNmjk0nOYjFN5ordC/Wjvg+RbYCyMSnW60l/Z0ZS82GbNltly6PMUH1w=="], + + "simplebar-react": ["simplebar-react@3.3.2", "", { "dependencies": { "simplebar-core": "^1.3.2" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-ZsgcQhKLtt5ra0BRIJeApfkTBQCa1vUPA/WXI4HcYReFt+oCEOvdVz6rR/XsGJcKxTlCRPmdGx1uJIUChupo+A=="], + + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + + "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "@emotion/babel-plugin/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + } +} diff --git a/dev-sec-fin-ops-challenge-v1/.gitignore b/dev-sec-fin-ops-challenge-v1/.gitignore deleted file mode 100644 index 9b8a46e692..0000000000 --- a/dev-sec-fin-ops-challenge-v1/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Local .terraform directories -**/.terraform/* - -# .tfstate files -*.tfstate -*.tfstate.* - -# Crash log files -crash.log -crash.*.log - -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. -*.tfvars -*.tfvars.json - -# Ignore override files as they are usually used to override resources locally and so -# are not checked in -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# Include override files you do wish to add to version control using negated pattern -# !example_override.tf - -# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan -# example: *tfplan* - -# Ignore CLI configuration files -.terraformrc -terraform.rc diff --git a/dev-sec-fin-ops-challenge-v1/README.md b/dev-sec-fin-ops-challenge-v1/README.md deleted file mode 100644 index 864bf895b7..0000000000 --- a/dev-sec-fin-ops-challenge-v1/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# Dynamox Dev-Sec-Fin-Ops Developer Challenge - -[< back to Dynamox Developer Challenges](https://github.com/dynamox-s-a/developer-challenges) - -In order to contribute to the enhancement of Dynamox solutions, we present you with the following challenge: - -Build a robust and intuitive infrastructure developed using Terraform, Kubernetes, Docker, and Google Cloud. It should include two Kubernetes workloads and brief analysis about DevOps, SecOps and FinOps. **This workloads are extremously complex: an extractor and an API that counts the number of successful requests. Please use this merely as a case of study.** - -While going through the challenge, you should be able to handle ambiguous situations, adhere to best practices in cloud development, and demonstrate excellent problem-solving skills. Effective communication through well-documented code, code quality, readability, and maintainability will also be evaluated. - -## User Stories and Functional Requirements - -Here you have the functional requirements for the application. You are free to make any assumptions you consider necessary to complete the challenge. If you have any questions, reply to the email who sent you this test. - ---- - -It is not mandatory to implement all the listed requirements before submitting your implementation. Just keep in mind that the more requirements you implement, the more you will be able to demonstrate your skills and knowledge. **Plan yourself to demonstrate what you want. Planning for future implementantion that are not required is also encoraged.** - -We will expect candidates applying to more senior levels to demonstrate a deeper understanding of the requirements and to implement / plan / propose more of them for the same deadline. - ---- - -You can use the following user stories as a guide to implement the application features. [Use dev-sec-fin-ops-challenge-answer-template.md as your answer template](./challenge-answer-template.md). - -1 - Backend Deployment - -1. [ ] As an user, I want to request (via a REST API) the number of successful requests received by the Backend Deployment. -1. [ ] As a developer, I want to run the service locally. -1. [ ] As a developer, I want to run the service in docker environment. -1. [ ] As a developer, I want to run the service in minikube environment. - -2 - Extraction Cronjob - -1. [ ] As an user, I want to access the requests that were extracted from Backend Deployment in a frequency of every-15-minutes by the Extractor Cronjob. -1. [ ] As a developer, I want to run the service locally. -1. [ ] As a developer, I want to run the service in docker environment. -1. [ ] As a developer, I want to run the service in minikube environment. - -3 - DevOps Analysis - -1. [ ] Describe the process to release a new version of these services and propose/implement automation pipelines. - -4 - SecOps Analysis - -1. [ ] List any security risks involving these services and propose mitigation actions. - -5 - FinOps Analysis - -1. [ ] Make 30-days and 365-days cost estimative of the services on Google Cloud, considering the following configurations. -**Attention! Do not use this number of pods in your test configuration, avoid cloud charges whenever is possible** - -| Attributes | Backend Deployment | Extraction Cronjob | -| -------------- | ------------------ | ------------------ | -| Machine Type | n1-highcpu-4 | n1-highmem-2 | -| Number of Pods | 55 | 28 | -| CPU | 1250m | 0.5 | -| Memory | 512Mi | 2Gi | - -6 - Bonus - -1. [ ] Draw a architectural diagram for this test. -1. [ ] Implement your own back-end code (NodeJS JavaScript runtime or Python is a differentiator). -1. [ ] Use Nest.js Framework (JS) or FastApi Framework (Python) for the back-end. -1. [ ] Use either PostgreSQL, MongoDB, or filesystem bucket-like solution (for example, Google Storage) as a persistence layer of your backend. -1. [ ] Implement logs for the applications (let us know how to find them, otherwise we won't be able to evaluate). -1. [ ] Implement unit tests for the application (let us know how to run them, otherwise we won't be able to evaluate). -1. [ ] Implement e2e tests (full user flow) with Cypress (NodeJs) or Pytest (Python). -1. [ ] If you were provided with a baseline code, identify any areas of bad code or suboptimal implementations, refactor them, and documment refactors. -1. [ ] Implement and document DevOps, SecOps, and FinOps improvements. -1. [ ] Implement an authentication/authorization layer. -1. [ ] Use service monitoring tools (such as Prometheus, Grafana). -1. [ ] Deploy your application to a cloud provider (Google Cloud is a differentiator) and provide a link for the running app. - -7 - Tips - -1. [ ] Not familiar with Terraform? Check out [these tutorials](https://developer.hashicorp.com/terraform/tutorials) to get started. -1. [ ] Not familiar with MiniKube? Check out [these tutorials](https://minikube.sigs.k8s.io/docs/tutorials/) to get started. -1. [ ] Not familiar with Docker? Check out [these tutorials](https://docs.docker.com/get-started/) to get started. -1. [ ] You can mock your back-end using a docker image like [nginx](https://hub.docker.com/_/nginx) or a package like [json-server](https://www.npmjs.com/package/json-server), which creates a fake REST API. Bear in mind that those implementing their own back-end will check more boxes in the evaluation process. - -
- -## Evaluation Criteria - -The items listed above will have different weights in the evaluation process. Each one of them will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". Use your judgement to prioritize the requirements you will implement in the time you have available. - -In general we will be looking for the following: - -1. [ ] Anyone should be able to follow the instructions and run the applications. -1. [ ] User stories were implemented according to the functional requirements. -1. [ ] Infrastructure code is successfully integrated with a backend APIs (either a fake one, or one you built yourself). -1. [ ] Infrastructure code is successfully integrated with local or cloud infrastructure. -1. [ ] Documment future implementations in a clear way. -1. [ ] Ability to refactor existing code (if applicable) and write tests for the written code. -1. [ ] Adherence to best practices in cloud development. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. - -## Ready to Begin the Challenges? - -1. [ ] Fork this repository to your own Github account. -1. [ ] Create a new branch using your first name and last name. For example: `caroline-oliveira`. -1. [ ] After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/dev-sec-fin-ops-developer-challenge), aimed at the main branch. -1. [ ] We will receive a notification about your pull request, review your solution, and get in touch with you. - -## Frequently Asked Questions - -1. Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -1. If I have more questions, who can I contact? - **Please reply to the email who sent you this test** - -1. Can I build my own back-end API? - **Yes, you can build your own back-end API, but it needs to use NodeJS or Python.** - -1. Can I use any NodeJS framework to the back-end? - **Yes, but we encourage you to use Nest.js. We are currently migrating away from pure ExpressJS and from Adonis.** - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 diff --git a/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md b/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md deleted file mode 100644 index 67ecbad26c..0000000000 --- a/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md +++ /dev/null @@ -1,41 +0,0 @@ -# Dynamox Dev-Sec-Fin-Ops Developer Challenge Answer Template - -The following is a template for your answer to [Dynamox Dev-Sec-Fin-Ops Developer Challenge](./README.md). - -## Initial Setup - -* [Git setup](https://git-scm.com/downloads) -* Editor setup ([Visual Studio Code](https://code.visualstudio.com/download) is a differentiator) - -## Test's Setup - -* Backend Deployment - * [O.S. environment setup](src/services/backend-deployment/README.md) - * [Docker environment setup](src/services/backend-deployment/README.md) - * [Minikube environment setup](src/infrastructure-as-code/README.md) - * Cloud environment setup -* Extractor Cronjob - * [O.S. environment setup](src/services/backend-deployment/README.md) - * [Docker environment setup](src/services/backend-deployment/README.md) - * [Minikube environment setup](src/infrastructure-as-code/README.md) - * Cloud environment setup - -## Test's architectural diagram - -TODO. - -## DevOps Brief Analysis - -TODO. - -## SecOps Brief Analysis - -TODO. - -## FinOps Brief Analysis - -TODO. - -## Future Implementations Plan - -TODO. diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md deleted file mode 100644 index f0c8eeef02..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md +++ /dev/null @@ -1,15 +0,0 @@ -# Provisioning - -Manual steps for provisioning Infrastructure as Code (IaC). - -## Install tools - -* [Terraform setup](https://developer.hashicorp.com/terraform/install), [for Windows tips](https://learn.microsoft.com/en-us/azure/developer/terraform/get-started-windows-bash?tabs=bash) -* [Docker setup](https://docs.docker.com/get-docker/) -* [Minikube setup](https://minikube.sigs.k8s.io/docs/start/) - -## Start Minikube Cluster - -```bash -minikube start -``` diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md deleted file mode 100644 index 41173b9c05..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Setup - -Setup for provisioning Infrastructure as Code (IaC). - -``` bash -. -└── infrastructure-as-code/ - ├── modules # Collection of multiple resources about the same service/solution. - ├── production # end-user environment - └── staging # test/developer environment -``` - -## Provisioning - -* [Provisioning](./PROVISIONING.md) - -## Apply Terraform IaC - -```bash -cd ./src/infrastructure-as-code/production -terraform init -terraform plan -terraform apply -``` - -## Validate Kubernetes workloads - -```bash -kubectl get pods -``` - -## Cleanup - -```bash -cd ./src/infrastructure-as-code/production -terraform destroy -minikube stop -minikube delete -``` diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml deleted file mode 100644 index a0b1a4133c..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -formatter: "markdown table" -version: "0.17.0" - -content: |- - {{ .Requirements }} - - {{ .Inputs }} - - {{ .Outputs }} - - {{ .Resources }} - -output: - file: README.md - mode: inject - -sections: - hide: [providers] - -output-values: - enabled: false - from: "" - -sort: - enabled: false - -settings: - anchor: true - color: true - default: true - description: false - escape: true - hide-empty: false - html: true - indent: 2 - lockfile: true - read-comments: true - required: true - sensitive: true - type: true diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md deleted file mode 100644 index f1d1f64675..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Backend Deployment Module - - -## Requirements - -| Name | Version | -|------|---------| -| [kubernetes](#requirement\_kubernetes) | ~> 2.27 | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [name](#input\_name) | Backend Deployment name | `string` | n/a | yes | -| [image](#input\_image) | Backend Deployment image | `string` | n/a | yes | -| [replicas](#input\_replicas) | Backend Deployment number of replicas | `string` | n/a | yes | -| [limits](#input\_limits) | Backend Deployment number of replicas |
object({
cpu = string
memory = string
})
| n/a | yes | -| [requests](#input\_requests) | Backend Deployment number of replicas |
object({
cpu = string
memory = string
})
| n/a | yes | - -## Outputs - -No outputs. - -## Resources - -| Name | Type | -|------|------| -| [kubernetes_deployment_v1.main](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/deployment_v1) | resource | - diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf deleted file mode 100644 index 61b8238cd8..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf +++ /dev/null @@ -1,49 +0,0 @@ -locals { - module_version = reverse(split("/", abspath(path.module)))[0] - module_name = reverse(split("/", abspath(path.module)))[1] -} - -resource "kubernetes_deployment_v1" "main" { - metadata { - name = var.name - labels = { - test = var.name - } - } - - spec { - replicas = var.replicas - - selector { - match_labels = { - test = var.name - } - } - - template { - metadata { - labels = { - test = var.name - } - } - - spec { - container { - image = var.image - name = var.name - - resources { - limits = { - cpu = var.limits.cpu - memory = var.limits.memory - } - requests = { - cpu = var.requests.cpu - memory = var.requests.memory - } - } - } - } - } - } -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/outputs.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/outputs.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/providers.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/providers.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf deleted file mode 100644 index f8d2114c84..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf +++ /dev/null @@ -1,30 +0,0 @@ -variable "name" { - description = "Backend Deployment name" - type = string -} - -variable "image" { - description = "Backend Deployment image" - type = string -} - -variable "replicas" { - description = "Backend Deployment number of replicas" - type = string -} - -variable "limits" { - description = "Backend Deployment number of replicas" - type = object({ - cpu = string - memory = string - }) -} - -variable "requests" { - description = "Backend Deployment number of replicas" - type = object({ - cpu = string - memory = string - }) -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf deleted file mode 100644 index be03bc4c0a..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf +++ /dev/null @@ -1,8 +0,0 @@ -terraform { - required_providers { - kubernetes = { - source = "hashicorp/kubernetes" - version = "~> 2.27" - } - } -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml deleted file mode 100644 index 16ab28e57a..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -formatter: "markdown table" -version: "0.16.0" - -content: |- - {{ .Requirements }} - - {{ .Inputs }} - - {{ .Outputs }} - - {{ .Resources }} - -output: - file: README.md - mode: inject - -sections: - hide: [providers] - -output-values: - enabled: false - from: "" - -sort: - enabled: false - -settings: - anchor: true - color: true - default: true - description: false - escape: true - hide-empty: false - html: true - indent: 2 - lockfile: true - read-comments: true - required: true - sensitive: true - type: true diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md deleted file mode 100644 index a7b79507be..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# $MODULE - - -## Requirements - -No requirements. - -## Inputs - -No inputs. - -## Outputs - -No outputs. - -## Resources - -No resources. - diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf deleted file mode 100644 index b8a40401bd..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -locals { - module_version = reverse(split("/", abspath(path.module)))[0] - module_name = reverse(split("/", abspath(path.module)))[1] -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/outputs.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/outputs.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/providers.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/providers.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/variables.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/variables.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/version.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/version.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf deleted file mode 100644 index 8b60bbc650..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf +++ /dev/null @@ -1,20 +0,0 @@ -provider "kubernetes" { - config_path = "~/.kube/config" - config_context = "minikube" -} - - -module "backend_deployment" { - source = "../modules/backend-deployment/v1.0.0" - name = "backend-deployment" - replicas = 1 - image = "nginx" - limits = { - cpu = "0.5" - memory = "250Mi" - } - requests = { - cpu = "0.25" - memory = "100Mi" - } -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf deleted file mode 100644 index dbe93f8120..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -provider "kubernetes" { - config_path = "~/.kube/config" - config_context = "minikube" -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md b/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md deleted file mode 100644 index 870fc50fa3..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Backend Deployment Service - -## O.S. Environment Setup - -* [Nodejs setup](https://nodejs.org/en/download) -* [Python setup](https://www.python.org/downloads/) - -TODO. - -## Docker Environment Setup - -TODO. diff --git a/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md b/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md deleted file mode 100644 index ef3d0b6e3d..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Extractor Cronjob Service - -## O.S. Environment Setup - -* [Nodejs setup](https://nodejs.org/en/download) -* [Python setup](https://www.python.org/downloads/) - -TODO. - -## Docker Environment Setup - -TODO. diff --git a/front-end-challenge-v1.md b/front-end-challenge-v1.md deleted file mode 100644 index 687edda5a0..0000000000 --- a/front-end-challenge-v1.md +++ /dev/null @@ -1,152 +0,0 @@ -# Dynamox Front-end Developer Challenge - -[< back to README](./README.md) - -Dynamox front-end development team presents you with the following challenge: - -**Using React and TypeScript, develop two modern web applications that demonstrate your expertise in frontend development.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Challenge 1: Landing Page 🌐 - -### Overview -In this challenge, you will create a modern and performant landing page that showcases your expertise in frontend development, SEO optimization. The landing page should be visually appealing, highly performant, and follow modern web development best practices. - -### Functional Requirements and User Stories - -In today's digital landscape, a well-crafted landing page is crucial for business success. Your challenge is to create a landing page that not only looks great but also performs exceptionally well in terms of loading speed, SEO, and user experience. - -1 - User Stories -1. [ ] As a user, I want to view a landing page that follows the provided [Figma design](https://www.figma.com/design/nN7CabevxBoFEoje0XZJ84/Test---Frontend---2025?node-id=365-20626&t=17l4SwF33pbLEndT-1). -2. [ ] As a user, I want the page to be fully responsive on all devices. -3. [ ] As a user, I want the page to load quickly with optimized images and assets. -4. [ ] As a user, I want to be able to change the images in the first section carousel by clicking on the respective image. -5. [ ] As a user, I want to click on the buttons with label "+ Leia mais" and be redirected to https://dynamox.net/blog. -6. [ ] As a user, in the footer, I want to click on the respective social media icons, text links and be redirected to the listed links on the Figma design. - - -2 - Technical Requirements -1. [ ] Use TypeScript. -2. [ ] Use React. -3. [ ] Implement comprehensive SEO best practices: - - Meta tags optimization - - Semantic HTML structure - - Social media meta tags -4. [ ] Cover the application logic with automated tests - - -3 - Bonus -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. -2. [ ] Achieve 95+ score on Lighthouse (Desktop). -3. [ ] Add smooth animations and micro-interactions. - - ---- - -## Challenge 2: Event Management System 🎟️ - -### Overview -In this challenge, you will develop a comprehensive event management system with role-based access control. The system should allow administrators to manage events while providing a streamlined experience for readers to view event information. - -### Authentication and User Roles - -#### Pre-configured Users -The system should have two pre-configured users in the [json-server](https://www.npmjs.com/package/json-server) database: -1. Admin User - - Email: admin@events.com - - Password: admin123 - - Role: admin - -2. Reader User - - Email: reader@events.com - - Password: reader123 - - Role: reader - -### Functional Requirements and User Stories - -#### Authentication & Authorization -1. [ ] As a user, I want to authenticate using the pre-configured email and password: - - [ ] Implement fake JWT token generation. - - [ ] Store token in localStorage. - - [ ] Include token in API requests headers. -2. [ ] As a user, I want to only access protected routes if I am authenticated. -3. [ ] As a user, I want to logout of the system. -4. [ ] As a user, I want to be redirected based on my role: - - Admin -> Admin Dashboard - - Reader -> Events List - -#### Admin Features (Role: admin) -1. [ ] As an admin, I want to create new events with the following information: - - [ ] Event name (required) - - [ ] Date and time (required, must be future date) - - [ ] Location (required) - - [ ] Description (required, min 50 characters) - - [ ] Category (required, select from: Conference, Workshop, Webinar, Networking, Other) -2. [ ] As an admin, I want to edit existing event details. -3. [ ] As an admin, I want to delete events. -4. [ ] As an admin, I want to view events. - -#### Reader Features (Role: reader) -1. [ ] As a reader, I want to view events. -2. [ ] As a reader, I want to view past events separately from upcoming events. -3. [ ] As a reader, I want to search and filter events. -4. [ ] As a reader, I want to sort events by: - - [ ] Date - - [ ] Name - -### Technical Requirements -1. [ ] Use TypeScript. -1. [ ] Use React. -2. [ ] Use Next.js [Check out](https://nextjs.org/docs/getting-started) to get started. -3. [ ] Implement state management using Redux Toolkit. -4. [ ] Create a mock REST API using [json-server](https://www.npmjs.com/package/json-server). -5. [ ] Use Material UI 6 for styling with custom theme configuration. -6. [ ] Ensure responsive design for all screen sizes. -7. [ ] Ensure correct business logic and behavior with automated unit tests. - -### Bonus -1. [ ] Add e2e tests with Cypress. [Check out](https://docs.cypress.io/guides/getting-started/installing-cypress) to get started. -2. [ ] Implement role-based route protection using HOCs or middleware. -3. [ ] Deploy your application to a cloud provider and provide a link for the running app. -4. [ ] Add Storybook documentation for UI components. - -## Evaluation Criteria - -Each one of the items above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -2. [ ] Front-end code is successfully integrated with a fake REST API. -3. [ ] Stories were implemented according to the functional requirements. -4. [ ] Problem-solving skills and ability to handle ambiguity. -5. [ ] Code quality, readability, and maintainability. -6. [ ] Code is well-organized and documented. -7. [ ] Application layout is responsive. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* Can I use create-react-app to complete the challenge? - **No, create-react-app is not acceptable for this challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** diff --git a/front-end-challenge-v2.md b/front-end-challenge-v2.md deleted file mode 100644 index 900f82a9d1..0000000000 --- a/front-end-challenge-v2.md +++ /dev/null @@ -1,81 +0,0 @@ -# Dynamox Front-end Developer Challenge - -[< back to README](./README.md) - -Dynamox front-end development team presents you with the following challenge: - -**Using React and TypeScript, develop a robust and intuitive dashboard application that allows assessing the data acquired by our sensors.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Functional Requirements and User Stories - -In maintenance industry, vibration analysis plays a main role: It uses physical quantities like acceleration and velocity to find evidences that help predict occurence of faults or degradations in machines. - -The page that displays this information in DynaPredict, our asset condition monitoring platform, is designed pretty much like [this](https://www.figma.com/file/QxUZkTUIzQA7cvyiMvVyxK/Front-end---Teste?type=design&node-id=1001%3A3&mode=design&t=JLnbGmQJcSlnYYE2-1). - -Use the figma as a reference to build an interface and develop the following user stories: - -1 - User Stories -1. [ ] As an user I want to acess the route */data* of my app and view a screen containing a small header with information about the machine and a couple of time-series charts. - -1. [ ] As an user I want to view 3 time-series charts of the following metrics: *acceleration, velocity and temperature*. Each time series should have a horizontal axis for the time and a vertical axis for the metric magnitude. - -1. [ ] As an user I want that the data that will populate these charts should be fetched each time I access the route */data*. Use the data available in [Responses](./response-challenge-v2.json) as a mock and use a package like [json-server](https://www.npmjs.com/package/json-server) to build an REST API. - -1. [ ] As an user I want to hover over a point of a chart and see a vertical crosshair marking equivalent timestamps in all time-series charts, with a tooltip describing that point. [Check out](https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/demo/synchronized-charts) to view a sample of this feature. - - -2 - Technical requirements -1. [ ] Use TypeScript. -1. [ ] Use React. -1. [ ] Use Redux for managing global states. [Check out](https://redux-toolkit.js.org/introduction/getting-started) to get started. -1. [ ] Use Redux Saga for asynchronous side effects. [Check out](https://redux-saga.js.org/docs/introduction/GettingStarted) to get started. -1. [ ] Use Vite for building the app. [Check out](https://vitejs.dev/guide/) to get started. -1. [ ] Use Material UI 5 for styling the application. [Check out](https://mui.com/material-ui/getting-started/) to get started. -1. [ ] Use Highcharts, Plotly, D3 or any similar library to display the plots. [Check out](https://www.highcharts.com/docs/index) to get started. -1. [ ] Ensure correct business logic and behavior with automated unit tests. - -We encourage using our frontend stack tooling to make the challenge resemble our everyday tasks. - -3 - Bonus -1. [ ] Use Storybook for documentation. [Check out](https://storybook.js.org/docs) to get started. -1. [ ] Add e2e tests with Cypress. [Check out](https://learn.cypress.io/) to get started. -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. - -## Evaluation Criteria - -Each one of the itens above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -1. [ ] Front-end code is successfully integrated with a fake REST API. -1. [ ] Stories were implemented according to the functional requirements. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. -1. [ ] Code is well-organized and documented. -1. [ ] Application layout is responsive. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** \ No newline at end of file diff --git a/full-stack-challenge.md b/full-stack-challenge.md deleted file mode 100644 index 0ee4a2bc7c..0000000000 --- a/full-stack-challenge.md +++ /dev/null @@ -1,156 +0,0 @@ -# Dynamox Full-Stack Developer Challenge - -[< back to README](./README.md) - -In order to contribute to the enhancement of our Corporate Channels and asset condition monitoring platform, DynaPredict, we present you with the following challenge: - -Build a robust and intuitive full-stack application developed using React and TypeScript for the frontend, and a suitable backend technology. The application should include authentication, machine management, sensor management, time-series data processing, and general user-friendly features. - -To properly address this challenge, you must deliver a fully integrated end‑to‑end flow, with frontend components connected to real backend endpoints and persistent storage. Implementing features only halfway — such as building UI components without backend integration — will not meet the challenge requirements. Complete integration between frontend and backend is more important than implementing isolated or unfinished client‑side features. - -While going through the challenge, you should be able to handle ambiguous situations, adhere to best practices in both front-end and back-end development, and demonstrate excellent problem-solving skills. Effective communication through well-documented code, code quality, readability, and maintainability will also be evaluated. - -## User Stories and Functional Requirements - -Here you have the functional requirements for the application. You are free to make any assumptions you consider necessary to complete the challenge. - ---- - -It is not mandatory to implement all the listed requirements before submitting your implementation. Just keep in mind that the more requirements you implement, the more you will be able to demonstrate your skills and knowledge. - -We will expect candidates applying to more senior levels to demonstrate a deeper understanding of the requirements and to implement more of them for the same deadline. - ---- - -You can use the following user stories as a guide to implement the application features: - -1 - Authentication - -1. [ ] As a user, I want to log in using a fixed email and password so that I can access private routes. -1. [ ] As a user, I want to be able to log out of the system so that I can prevent unauthorized access to my account. -1. [ ] No private routes should be accessible without authentication. - -2 - Machine Management - -1. [ ] As a user, I want to create a new machine with an arbitrary name and with a type selected from a list ["Pump", "Fan"] so that I can manage it later. -1. [ ] As a user, I want to change the attributes (name and type) of a machine after creating it so that I can keep the machine information updated. -1. [ ] As a user, I want to delete a machine when it is no longer in use so that it doesn't clutter the system. - -3 - Monitoring Points and Sensors Management - -1. [ ] As a user, I want to create at least two monitoring points with arbitrary names for an existing machine, so that I can monitor the machine's performance. -1. [ ] As a user, I want to associate a sensor to an existing monitoring point so that I can monitor the machine's performance. The sensor should have a unique ID, and the sensor model name should be one of ["TcAg", "TcAs", "HF+"]. -1. [ ] As a user, I want the system to prevent me from setting up "TcAg" and "TcAs" sensors for machines of the type "Pump". -1. [ ] As a user, I want to see all my monitoring points in a paginated list so that I can manage them. The list should display up to 5 monitoring points per page and should include the following information: "Machine Name", "Machine Type", "Monitoring Point Name", and "Sensor Model". -1. [ ] As a user, I want to sort the monitoring points list by any of its columns in ascending or descending order, so that I can easily find the information I'm looking for. - -4 - Ambiguity Handling - -1. [ ] Make reasonable assumptions and design the application accordingly for any ambiguities in the challenge. -1. [ ] Document your assumptions in the README file. - -5 - Technical requirements - -1. [ ] Use TypeScript. -1. [ ] Use React. -1. [ ] Use Redux for managing global states. -1. [ ] Use Redux Thunks or Redux Saga for managing asynchronous side effects. -1. [ ] Use Next.js or Vite. -1. [ ] Use Material UI 5 for styling the application. -1. [ ] Create reusable components. -1. [ ] The code is well-organized and documented. -1. [ ] The application layout is responsive. -1. [ ] Ensure correct business logic and behavior with automated unit tests. - -6 - Back-end Requirements - -1. [ ] Implement your own back-end code using NodeJS JavaScript runtime (not Java, not PHP...). Although we also work with Python here, we are looking for JavaScript related skills in this test. -1. [ ] Use either PostgreSQL or MongoDB as a persistence layer. -1. [ ] If you choose to use PostgreSQL, use Prisma ORM (or even try Drizzle, or Kysely) to interact with PostgreSQL. -1. [ ] If you choose to use MongoDB, use Mongoose ORM to interact with the database. -1. [ ] Implement RESTful API endpoints for all required functionality (authentication, machine management, sensor management). -1. [ ] Implement time-series data storage and retrieval functionality for sensor data. -1. [ ] Ensure the API has proper error handling and validation. -1. [ ] Implement unit tests for backend code. -1. [ ] The latency between client and the server side should be below 350ms for all requests. - -7 - Time-Series Data Management - -1. [ ] As a user, I want to be able to store raw sensor data as time-series for my monitoring points. -1. [ ] As a user, I want to be able to retrieve metrics about the time-series data for my sensors. -1. [ ] As a user, I want to be able to delete time-series data I've sent to the server. -1. [ ] As a user, I want to be able to retrieve the number of time-series I've stored in the server. -1. [ ] As a user, I want to be able to retrieve a full time-series I've stored. -1. [ ] As a user, I want to be able to visualize time-series data in a chart or graph format. - -8 - Bonus - -1. [ ] Use Nx to manage the whole application as a monorepo (we use that tool a lot here). -1. [ ] Add e2e tests with Cypress (use it to test a full user flow). -1. [ ] If you were provided with a baseline code, identify any areas of bad code or suboptimal implementations and refactor them. -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. -1. [ ] Implement a functionality that gives a future prediction of the time-series data. -1. [ ] Add load balancer to the application. -1. [ ] Add load tests to the application. - -9 - Tips - -1. [ ] There is no need to reinvent the wheel. You can use a Material UI 5 free template like [Devias Kit](https://mui.com/store/items/devias-kit/) to speed up the development process. -1. [ ] Not familiar with Redux? Check out [this tutorial](https://egghead.io/courses/modern-redux-with-redux-toolkit-rtk-and-typescript-64f243c8) to get started. -1. [ ] Not familiar with Cypress? Check out [these tutorials](https://learn.cypress.io/) to get started. -1. [ ] For time-series data visualization, consider using libraries like [Recharts](https://recharts.org/), [Chart.js](https://www.chartjs.org/), or [D3.js](https://d3js.org/). -
- - -## Evaluation Criteria - -The items listed above will have different weights in the evaluation process. Each one of them will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". Use your judgement to prioritize the requirements you will implement in the time you have available. - -In general we will be looking for the following: - -1. [ ] Anyone should be able to follow the instructions and run the application. -1. [ ] User stories were implemented according to the functional requirements. -1. [ ] Front-end code is successfully integrated with a back-end API. -1. [ ] Back-end code successfully integrates with a persistent storage solution. -1. [ ] Time-series data is properly stored, processed, and visualized. -1. [ ] Ability to refactor existing code (if applicable) and write unit tests for both front-end and back-end code. -1. [ ] Adherence to best practices in both front-end and back-end development. -1. [ ] API performance meets the latency requirements. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. -1. [ ] Code is well-organized and documented. -1. [ ] Use of atomic and semantic commits following a clear commit message convention (e.g., Conventional Commits). - -## Ready to Begin the Challenges? - -1. [ ] Fork this repository to your own Github account. -1. [ ] Create a new branch using your first name and last name. For example: `caroline-oliveira`. -1. [ ] After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test), aimed at the main branch. -1. [ ] We will receive a notification about your pull request, review your solution, and get in touch with you. - -## Frequently Asked Questions - -1. Can I use create-react-app to complete the challenge? - **No, create-react-app is not acceptable for this challenge.** - -1. Can I use Next.js or Vite to complete the challenge? - **Yes, you should use of either Next.js or Vite for this challenge.** - -1. Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -1. Can I use Material UI in the project? - **Yes, the use of Material UI 5 is mandatory for this challenge.** - -1. If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** - -1. Is implementing a back-end API required? - **Yes, implementing your own back-end API is now a core requirement, not a bonus. It needs to use NodeJS.** - -1. Can I use AI to complete the challenge? - **Yes, however, keep in mind you will need to explain your decisions and code.** - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 diff --git a/full-stack-csharp-react-challenge.md b/full-stack-csharp-react-challenge.md deleted file mode 100644 index 64e513308a..0000000000 --- a/full-stack-csharp-react-challenge.md +++ /dev/null @@ -1,77 +0,0 @@ -# Full-Stack Development Challenge - C# and React - -## Overview -To help us evolve our asset condition monitoring platform, **DynaPredict**, we present the following challenge: - -Build a robust and intuitive application (front-end and back-end) for managing industrial machines. With this system, a user should be able to register, view, and manage their industry's machines. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - ---- - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Machine Management -- [ ] As a user, I want to register a new machine by providing its **Name**, **Serial Number**, **Description**, and selecting a **Machine Type** from a predefined list (e.g., "Press", "Lathe", "Milling Machine"), so that I can manage it later. -- [ ] As a user, I want to view a list of all registered machines, displaying at least the **Name**, **Serial Number**, and **Type**, to get an overview of my factory floor. -- [ ] As a user, I want to click on a machine in the list to see its full details on a dedicated page. -- [ ] As a user, I want the system to prevent the registration of a machine without the required fields (**Name**, **Serial Number**, **Type**). - ---- - -## Mandatory Technical Requirements - -### 2 - Back-end -- [ ] The application must be written in **C#**, using **.NET 6 (or higher)** and **ASP.NET Core**. -- [ ] Use a data persistence mechanism. File or preferably **SQL Server** or **PostgreSQL** databases. -- [ ] Expose a **RESTful API** with the following endpoints: - - `GET /api/machines` — returns all machines. - - `GET /api/machines/{id}` — returns the machine with the specified ID. - - `POST /api/machines` — creates a new machine. -- [ ] If you use database, provide **Entity Framework Core migrations** or a **SQL script** to create the entire database structure. - -### 3 - Front-end -- [ ] The application must be developed in **React with TypeScript**. -- [ ] Implement the following screens: - - A screen for **machine creation** (form). - - A screen to **display the list of machines**. - - A screen to **display the details of a single machine**. -- [ ] The creation form must have **validation for required fields**. - ---- - -## Bonus Points (Optional Requirements) -These items (4 and 5) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 4 - Best Practices & Architecture (Back-end) -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Use **Entity Framework Core** as the ORM. -- [ ] Implement the **Repository Pattern**. -- [ ] Generate API documentation using **Swagger (Swashbuckle)**. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 5 - Quality & DevOps -- [ ] Write **unit or integration tests** for the main business logic. -- [ ] Provide **Dockerfile(s)** and a **docker-compose.yml** file to initialize the API, database, and front-end. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria -Your solution will be evaluated based on the following criteria: - -- **Functionality**: The user stories were implemented correctly. -- **Back-end Quality**: Clean, well-structured code, application of design patterns and architecture. -- **Front-end Quality**: Componentization, folder structure, state management, and responsiveness. -- **Best Practices**: Adherence to technical requirements and implementation of optional items. -- **Documentation and Ease of Execution**: Clarity of the README and ease of setting up and running the project. - ---- - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. diff --git a/ios-challenge.md b/ios-challenge.md deleted file mode 100644 index ea65fb9469..0000000000 --- a/ios-challenge.md +++ /dev/null @@ -1,131 +0,0 @@ -# Dynamox iOS Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive iOS Quiz application in Swift. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Swift** -- [ ] Use **CocoaPods** for dependency management -- [ ] Use some data persistence mechanism to store players and scores -- [ ] Use **UIKit and SwiftUI** for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Async await/Combine** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria - -- Technical capability -- iOS knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. \ No newline at end of file diff --git a/kotlin-multiplatform-challenge.md b/kotlin-multiplatform-challenge.md deleted file mode 100644 index 818eb9b2a1..0000000000 --- a/kotlin-multiplatform-challenge.md +++ /dev/null @@ -1,134 +0,0 @@ -# Dynamox Kotlin Multiplatform Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive multiplatform (Android and iOS) Quiz application in Kotlin. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Kotlin** using **Kotlin Multiplatform** -- [ ] The application must run for **Android** -- [ ] Use a data persistence mechanism to store players and scores -- [ ] Use Jetpack Compose for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Kotlin Flow/Coroutines** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - -### 3 - Multiplatform -- [ ] Application running in both Android and iOS devices - ---- - -## Evaluation Criteria - -- Technical capability -- Android, iOS and KMP knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. diff --git a/package.json b/package.json new file mode 100644 index 0000000000..96ab873b75 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "dynamox-challenge", + "private": true, + "type": "module", + "workspaces": { + "packages": ["apps/*", "packages/*"], + "catalog": { + "@orpc/client": "1.14.8", + "@orpc/contract": "1.14.8", + "@orpc/openapi": "1.14.8", + "@orpc/openapi-client": "1.14.8", + "@orpc/react-query": "1.14.8", + "@orpc/server": "1.14.8", + "@orpc/zod": "1.14.8", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "typescript": "^5.9.2", + "vitest": "^3.2.4", + "zod": "^4.1.11" + } + }, + "scripts": { + "dev": "bun run --filter '*' dev", + "dev:backend": "bun run --filter backend dev", + "dev:frontend": "bun run --filter frontend dev", + "build": "bun run --filter frontend build", + "test": "bun run --filter '*' test", + "typecheck": "bun run --filter '*' typecheck" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000000..ed32d9ab53 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@repo/contracts", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@orpc/contract": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000000..8f7d6cc580 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,7 @@ +import { machines } from './modules/machines/route'; +import { measurements } from './modules/measurements/route'; + +export const contracts = { machines, measurements }; + +export * from './modules/machines/schema'; +export * from './modules/measurements/schema'; diff --git a/packages/contracts/src/modules/machines/route.ts b/packages/contracts/src/modules/machines/route.ts new file mode 100644 index 0000000000..2e00d5f04f --- /dev/null +++ b/packages/contracts/src/modules/machines/route.ts @@ -0,0 +1,17 @@ +import { oc } from '@orpc/contract'; +import { z } from 'zod'; + +import { machineSchema, machinesListSchema } from './schema'; + +const tag = oc.route({ tags: ['Machines'] }); + +export const machines = oc.prefix('/machines').router({ + list: tag + .route({ method: 'GET', path: '/', summary: 'Lista as máquinas monitoradas' }) + .output(machinesListSchema), + + get: tag + .route({ method: 'GET', path: '/{id}', summary: 'Detalha uma máquina' }) + .input(z.object({ id: z.string() })) + .output(machineSchema), +}); diff --git a/packages/contracts/src/modules/machines/schema.ts b/packages/contracts/src/modules/machines/schema.ts new file mode 100644 index 0000000000..cbe80e8ef1 --- /dev/null +++ b/packages/contracts/src/modules/machines/schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const sensorSchema = z.object({ + id: z.string(), + model: z.string(), + serialNumber: z.string(), + position: z.string(), +}); + +export const machineSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + manufacturer: z.string(), + rpm: z.number(), + status: z.enum(['operational', 'alert', 'critical', 'stopped']), + sensor: sensorSchema, + lastReadingAt: z.string(), +}); + +export const machinesListSchema = z.object({ + data: z.array(machineSchema), +}); + +export type Sensor = z.infer; +export type Machine = z.infer; +export type MachineStatus = Machine['status']; diff --git a/packages/contracts/src/modules/measurements/route.ts b/packages/contracts/src/modules/measurements/route.ts new file mode 100644 index 0000000000..e9b1234993 --- /dev/null +++ b/packages/contracts/src/modules/measurements/route.ts @@ -0,0 +1,16 @@ +import { oc } from '@orpc/contract'; + +import { measurementsQuerySchema, measurementsSchema } from './schema'; + +const tag = oc.route({ tags: ['Measurements'] }); + +export const measurements = oc.prefix('/machines').router({ + list: tag + .route({ + method: 'GET', + path: '/{machineId}/measurements', + summary: 'Séries temporais de aceleração, velocidade e temperatura de uma máquina', + }) + .input(measurementsQuerySchema) + .output(measurementsSchema), +}); diff --git a/packages/contracts/src/modules/measurements/schema.ts b/packages/contracts/src/modules/measurements/schema.ts new file mode 100644 index 0000000000..75d93a87c3 --- /dev/null +++ b/packages/contracts/src/modules/measurements/schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const metricSchema = z.enum(['acceleration', 'velocity', 'temperature']); + +export const axisSchema = z.enum(['x', 'y', 'z']); + +export const dataPointSchema = z.object({ + datetime: z.string(), + max: z.number(), +}); + +export const seriesStatsSchema = z.object({ + min: z.number(), + max: z.number(), + avg: z.number(), + last: z.number(), +}); + +export const seriesSchema = z.object({ + id: z.string(), + metric: metricSchema, + axis: axisSchema.nullable(), + unit: z.string(), + points: z.array(dataPointSchema), + stats: seriesStatsSchema, +}); + +export const measurementsQuerySchema = z.object({ + machineId: z.string(), + from: z.string().optional(), + to: z.string().optional(), + metrics: z.array(metricSchema).optional(), +}); + +export const measurementsSchema = z.object({ + machineId: z.string(), + series: z.array(seriesSchema), +}); + +export type Metric = z.infer; +export type Axis = z.infer; +export type DataPoint = z.infer; +export type SeriesStats = z.infer; +export type Series = z.infer; +export type Measurements = z.infer; +export type MeasurementsQuery = z.infer; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000000..0ddb452024 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "types": [] + }, + "include": ["src"] +} diff --git a/qa-challenge.md b/qa-challenge.md deleted file mode 100644 index db517fb8eb..0000000000 --- a/qa-challenge.md +++ /dev/null @@ -1,60 +0,0 @@ -# QA Test for Web Applications - -In this challenge, we will evaluate your ability to develop automated tests for a web application responsible for displaying data from a vibration and temperature sensor. - -Consider the following development flow. The Product team has created the functional requirements and provided the following [file](https://www.figma.com/file/QxUZkTUIzQA7cvyiMvVyxK/Front-end---Teste?type=design&node-id=1001%3A3&mode=design&t=JLnbGmQJcSlnYYE2-1) containing the screen prototype. The product requirements are: - -1. As a user, I want to view a screen containing a small header with machine information and some charts. -2. As a user, I want to view 3 time series charts for RMS Acceleration, RMS Velocity, and Temperature. -3. As a user, I want the data to be refreshed every time I access the page. -4. As a user, when hovering over the time series, I want to see a tooltip displaying the data values. - -To obtain the data, the following requests are made: - -* **GET** request to the */data* route. Contains time series data that will be displayed in the charts. For the purposes of this test, the data is static. -* **GET** request to the */metadata* route. Contains information associated with the monitoring point that will be displayed in the header. - -The web application is available at this [link](https://frontend-test-for-qa.vercel.app/). - -## Test Requirements - -The product requirements represent macro-journeys, so also consider implementation details: - -* Can the user complete this journey? -* Does the implementation meet all specifications of the prototype? -* Are there any strange or unexpected behaviors? - -Implement automated tests for each scenario you find appropriate. Tests are expected to pass where the criteria are met and fail where they are not. - -## Evaluation Criteria - -The following items will be evaluated: - -* Organization and structure of the test repository. -* Code documentation and readability. -* Test quality and coverage. - -Also consider: - -* Found a defect but don’t know how to create an automated test for it? Describe how you would report the issue to the developer. -* Found a product requirement not specified in the prototype? Describe how you would report it to the designer. -* There is no minimum or maximum number of tests. Find a balance between software robustness and test execution time. -* The choice of framework is up to you. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.**