diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..205907a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +*.egg-info/ +dist/ +build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d0ab99 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,228 @@ +# Guia de Contribuição + +Este documento define as práticas e convenções adotadas no projeto. Todo membro da equipe — humano ou agente IA — deve segui-las. + +--- + +## 1. Estratégia de branches principais + +O repositório possui duas branches de longa duração. Nenhuma delas recebe commits diretos. + +``` +main ──────────────────────────────────────────────► produção + ▲ ▲ + │ merge (release ou hotfix) │ hotfix + │ │ +develop ──────────────────────────────────► merge antes da release + ▲ ▲ ▲ + │ │ │ +feat/x fix/y refactor/z ← branches de trabalho (curta duração) +``` + +### `main` + +- Representa o estado **atual de produção** +- Todo commit em `main` é uma versão entregável e estável +- Só recebe merges vindos de `develop` (via release) ou de `hotfix/*` +- Cada merge em `main` deve ser **tagueado** com a versão: `v1.2.0` + +### `develop` + +- Branch de **integração contínua** — onde o trabalho da equipe converge +- Deve sempre estar em estado funcional (CI passando) +- Branches de feature, fix e refactor partem daqui e retornam aqui via PR +- Periodicamente promovida para `main` quando um conjunto de funcionalidades está pronto para release + +### Fluxo resumido + +``` +1. Partir de develop + git checkout develop && git pull + git checkout -b feat/42-nome-da-feature + +2. Desenvolver com commits atômicos + +3. Abrir PR de feat/42-... → develop + (revisão, CI, aprovação) + +4. Merge em develop (squash ou merge commit — decisão do time) + +5. Quando develop está pronta para ir a produção: + PR develop → main + tag da versão +``` + +### Hotfix (bug crítico em produção) + +``` +main → hotfix/99-descricao → PR para main + PR para develop +``` + +Um hotfix parte de `main`, é corrigido, e depois é integrado em `develop` também — para não perder a correção na próxima release. + +--- + +## 2. Commits + +### Formato (Conventional Commits) + +``` +(): + +[corpo opcional — o quê e por quê, não como] + +[rodapé opcional: refs, breaking changes] +``` + +**Tipos permitidos:** + +| Tipo | Quando usar | +|------------|-----------------------------------------------------------| +| `feat` | Nova funcionalidade visível ao usuário/sistema | +| `fix` | Correção de bug | +| `refactor` | Mudança interna sem alterar comportamento externo | +| `test` | Adição ou correção de testes | +| `docs` | Apenas documentação | +| `chore` | Build, CI, dependências, configs — sem código de produção | +| `style` | Formatação, espaçamento — sem mudança de lógica | +| `perf` | Melhoria de performance | +| `revert` | Reverte um commit anterior | + +**Escopo:** camada ou módulo afetado (`domain`, `auth`, `api`, `ui`, `ci`, etc.). + +**Exemplos válidos:** + +``` +feat(auth): adicionar autenticação via token JWT + +fix(domain): corrigir cálculo de desconto para pedidos fracionados + +refactor(user): extrair validação de e-mail para classe dedicada + +docs(adr): registrar decisão de uso de event sourcing (ADR-004) +``` + +**Regras:** +- Descrição em **português**, no **imperativo** ("adicionar", não "adicionei" ou "adicionando") +- Máximo de **72 caracteres** na primeira linha +- Um commit = uma responsabilidade. Se precisar de "e" na descrição, considere dividir +- Commits de trabalho em progresso devem usar `wip:` e ser squashados antes do merge + +--- + +## 3. Branches + +``` +/- +``` + +``` +feat/42-autenticacao-jwt +fix/87-calculo-desconto-fracionado +refactor/user-validacao-email +docs/adr-event-sourcing +``` + +**Regras:** +- Nunca commitar diretamente em `main` ou `develop` +- Branches de feature e fix partem de `develop` (ver seção 1) +- Hotfixes partem de `main` e são integrados em `main` e `develop` +- Delete a branch após o merge + +--- + +## 4. Práticas de Clean Code + +> Independentes de linguagem. Válidas para qualquer camada. + +### 3.1 Nomes revelam intenção + +``` +// ❌ +int d; +bool flag; +void process(List x); + +// ✅ +int diasAteVencimento; +bool usuarioEstaAtivo; +void processarPedidosPendentes(List pedidos); +``` + +- Nomes de **variáveis e funções**: descrevem *o que são* ou *o que fazem* +- Nomes de **classes**: substantivos (`Pedido`, `AutenticadorDeUsuario`) +- Nomes de **funções/métodos**: verbos (`calcular`, `validar`, `buscar`) +- Evite abreviações que exijam contexto mental (`usrMgr` → `gerenciadorDeUsuario`) +- Evite prefixos redundantes (`pedido.pedidoId` → `pedido.id`) + +### 3.2 Funções fazem uma coisa + +- Uma função cabe em uma tela sem scroll → suspeite se precisar de mais +- Se você precisar usar "e" para descrever o que ela faz, extraia +- Parâmetros: idealmente 0–2; 3 é o limite; acima disso, agrupe em objeto + +### 3.3 Não deixe comentários onde o código pode falar + +``` +// ❌ Comentário que repete o código +// Verifica se o usuário é maior de idade +if (usuario.idade >= 18) { ... } + +// ✅ Nome que dispensa o comentário +if (usuario.eMaiorDeIdade()) { ... } +``` + +Comentários válidos: decisões de negócio não óbvias, workarounds com link para issue, advertências sobre efeitos colaterais. + +### 3.4 Sem números mágicos ou strings soltas + +``` +// ❌ +if (tentativas > 3) { ... } +status = "APROVADO"; + +// ✅ +const val MAX_TENTATIVAS_LOGIN = 3; +if (tentativas > MAX_TENTATIVAS_LOGIN) { ... } +status = StatusPedido.APROVADO; +``` + +### 3.5 Regra do Escoteiro + +> Deixe o código *ligeiramente* melhor do que você encontrou. + +Não é refactoring massivo — é renomear uma variável confusa, extrair um bloco repetido, remover um TODO resolvido. + +### 3.6 Testes são cidadãos de primeira classe + +- Todo bug corrigido ganha um teste que o reproduz antes do fix +- Nomes de teste descrevem o cenário: `deve_retornar_erro_quando_email_invalido` +- Um teste = um comportamento verificado +- Testes não devem depender de ordem de execução + +--- + +## 5. Pull Requests + +**Título:** mesmo formato de commit (`feat(auth): adicionar login com Google`) + +**Checklist antes de abrir o PR:** +- [ ] Testes passando localmente +- [ ] Cobertura não regrediu +- [ ] Nenhum `TODO` novo sem issue associada +- [ ] Documentação atualizada (se aplicável) +- [ ] ADR criado (se decisão arquitetural foi tomada) + +**Tamanho:** PRs grandes são difíceis de revisar. Prefira PRs focados. Se ultrapassar ~400 linhas, justifique ou divida. + +--- + +## 6. Para Agentes IA + +Ao atuar neste repositório, siga adicionalmente: + +1. **Leia `docs/specs/` antes de implementar** — ali está o contrato do que deve ser feito +2. **Leia o ADR relevante** antes de propor mudanças arquiteturais +3. **Nunca altere contratos públicos** (interfaces, APIs, eventos) sem registrar um novo ADR +4. **Prefira edições cirúrgicas** a reescritas completas de arquivos existentes +5. **Documente suposições** no corpo do commit quando o requisito for ambíguo +6. **Não invente dependências** externas sem registrar no PR por que são necessárias diff --git a/README.md b/README.md new file mode 100644 index 0000000..40caa26 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Magic Store Management System + +## Backend + +```bash +cd backend +pip install -e ".[dev]" +uvicorn app.main:app --reload +``` + +API em `http://localhost:8000`. + +## Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Painel admin em `http://localhost:3000/admin`. + +Variável `NEXT_PUBLIC_API_URL` (padrão: `http://localhost:8000`) em `frontend/.env.local`. diff --git a/RUN.md b/RUN.md new file mode 100644 index 0000000..09f7200 --- /dev/null +++ b/RUN.md @@ -0,0 +1,25 @@ +# Backend + +```bash +cd backend +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +uvicorn app.main:app --reload +``` + +# Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +# Testes + +```bash +cd backend +source .venv/bin/activate +pytest -v +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/produto_router.py b/backend/app/api/produto_router.py new file mode 100644 index 0000000..368f8ae --- /dev/null +++ b/backend/app/api/produto_router.py @@ -0,0 +1,125 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.router import obter_facade +from app.api.schemas import ( + ProdutoAtualizacao, + ProdutoCriacao, + ProdutoResposta, + QuantidadeEntidadesResposta, +) +from app.domain.excecoes import ( + DadosInvalidosError, + PersistenciaError, + ProdutoNaoEncontradoError, + UsuarioNaoEncontradoError, +) +from app.domain.produto import Produto +from app.service.facade_controller import FacadeSingletonController + +router = APIRouter(prefix="/produtos", tags=["produtos"]) + + +def converter_para_resposta(produto: Produto) -> ProdutoResposta: + return ProdutoResposta( + id=produto.id, + nome=produto.nome, + tipo=produto.tipo, + preco=produto.preco, + quantidade_estoque=produto.quantidade_estoque, + gerente_id=produto.gerente_id, + ) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ProdutoResposta) +def criar_produto( + dados: ProdutoCriacao, + facade: FacadeSingletonController = Depends(obter_facade), +) -> ProdutoResposta: + try: + produto = facade.cadastrar_produto( + dados.nome, + dados.tipo, + dados.preco, + dados.quantidade_estoque, + dados.gerente_id, + ) + except DadosInvalidosError as erro: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(erro)) from erro + except UsuarioNaoEncontradoError as erro: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(erro)) from erro + except PersistenciaError as erro: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(erro) + ) from erro + + return converter_para_resposta(produto) + + +@router.get("", response_model=list[ProdutoResposta]) +def listar_produtos( + facade: FacadeSingletonController = Depends(obter_facade), +) -> list[ProdutoResposta]: + return [converter_para_resposta(produto) for produto in facade.listar_produtos()] + + +@router.get("/quantidade", response_model=QuantidadeEntidadesResposta) +def contar_entidades( + facade: FacadeSingletonController = Depends(obter_facade), +) -> QuantidadeEntidadesResposta: + return QuantidadeEntidadesResposta( + quantidade=facade.quantidade_entidades_cadastradas() + ) + + +@router.get("/{produto_id}", response_model=ProdutoResposta) +def buscar_produto( + produto_id: int, + facade: FacadeSingletonController = Depends(obter_facade), +) -> ProdutoResposta: + try: + produto = facade.buscar_produto(produto_id) + except ProdutoNaoEncontradoError as erro: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(erro)) from erro + + return converter_para_resposta(produto) + + +@router.put("/{produto_id}", response_model=ProdutoResposta) +def atualizar_produto( + produto_id: int, + dados: ProdutoAtualizacao, + facade: FacadeSingletonController = Depends(obter_facade), +) -> ProdutoResposta: + try: + produto = facade.atualizar_produto( + produto_id, + dados.nome, + dados.tipo, + dados.preco, + dados.quantidade_estoque, + ) + except DadosInvalidosError as erro: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(erro)) from erro + except ProdutoNaoEncontradoError as erro: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(erro)) from erro + except PersistenciaError as erro: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(erro) + ) from erro + + return converter_para_resposta(produto) + + +@router.delete("/{produto_id}", status_code=status.HTTP_204_NO_CONTENT) +def remover_produto( + produto_id: int, + facade: FacadeSingletonController = Depends(obter_facade), +) -> None: + try: + facade.remover_produto(produto_id) + except ProdutoNaoEncontradoError as erro: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(erro)) from erro + except PersistenciaError as erro: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(erro) + ) from erro diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..d86df0d --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.schemas import UsuarioCriacao, UsuarioLogin, UsuarioResposta +from app.domain.excecoes import ( + CredenciaisInvalidasError, + DadosInvalidosError, + EmailDuplicadoError, + LoginDuplicadoError, + PersistenciaError, +) +from app.domain.usuario import Usuario +from app.service.facade_controller import FacadeSingletonController + +router = APIRouter(prefix="/usuarios", tags=["usuarios"]) + + +def obter_facade() -> FacadeSingletonController: + return FacadeSingletonController.obter_instancia() + + +def converter_para_resposta(usuario: Usuario) -> UsuarioResposta: + return UsuarioResposta( + id=usuario.id, + nome=usuario.nome, + email=usuario.email, + login=usuario.login, + perfil=usuario.perfil, + ) + + +def _cadastrar(cadastrar, dados: UsuarioCriacao) -> UsuarioResposta: + try: + usuario = cadastrar(dados.nome, dados.email, dados.login, dados.senha) + except DadosInvalidosError as erro: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(erro), + ) from erro + except (EmailDuplicadoError, LoginDuplicadoError) as erro: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(erro), + ) from erro + except PersistenciaError as erro: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(erro), + ) from erro + + return converter_para_resposta(usuario) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=UsuarioResposta) +def criar_usuario( + dados: UsuarioCriacao, + facade: FacadeSingletonController = Depends(obter_facade), +) -> UsuarioResposta: + return _cadastrar(facade.cadastrar_usuario, dados) + + +@router.post( + "/gerentes", status_code=status.HTTP_201_CREATED, response_model=UsuarioResposta +) +def criar_gerente( + dados: UsuarioCriacao, + facade: FacadeSingletonController = Depends(obter_facade), +) -> UsuarioResposta: + return _cadastrar(facade.cadastrar_gerente, dados) + + +@router.post("/login", response_model=UsuarioResposta) +def autenticar_usuario( + dados: UsuarioLogin, + facade: FacadeSingletonController = Depends(obter_facade), +) -> UsuarioResposta: + try: + usuario = facade.autenticar_usuario(dados.login, dados.senha) + except CredenciaisInvalidasError as erro: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(erro), + ) from erro + except PersistenciaError as erro: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(erro), + ) from erro + + return converter_para_resposta(usuario) + + +@router.get("", response_model=list[UsuarioResposta]) +def listar_usuarios( + facade: FacadeSingletonController = Depends(obter_facade), +) -> list[UsuarioResposta]: + usuarios = facade.listar_usuarios() + return [converter_para_resposta(usuario) for usuario in usuarios] diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..d484fa7 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,49 @@ +from pydantic import BaseModel, EmailStr + + +class UsuarioCriacao(BaseModel): + nome: str + email: EmailStr + login: str + senha: str + + +class UsuarioLogin(BaseModel): + login: str + senha: str + + +class UsuarioResposta(BaseModel): + id: int + nome: str + email: str + login: str + perfil: str + + +class ProdutoCriacao(BaseModel): + nome: str + tipo: str + preco: float + quantidade_estoque: int + gerente_id: int + + +class ProdutoAtualizacao(BaseModel): + nome: str + tipo: str + preco: float + quantidade_estoque: int + + +class ProdutoResposta(BaseModel): + id: int + nome: str + tipo: str + preco: float + quantidade_estoque: int + gerente_id: int + + +class QuantidadeEntidadesResposta(BaseModel): + quantidade: int diff --git a/backend/app/domain/__init__.py b/backend/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/domain/excecoes.py b/backend/app/domain/excecoes.py new file mode 100644 index 0000000..bee7e56 --- /dev/null +++ b/backend/app/domain/excecoes.py @@ -0,0 +1,26 @@ +class DadosInvalidosError(Exception): + pass + + +class EmailDuplicadoError(Exception): + pass + + +class LoginDuplicadoError(Exception): + pass + + +class UsuarioNaoEncontradoError(Exception): + pass + + +class ProdutoNaoEncontradoError(Exception): + pass + + +class CredenciaisInvalidasError(Exception): + pass + + +class PersistenciaError(Exception): + pass diff --git a/backend/app/domain/produto.py b/backend/app/domain/produto.py new file mode 100644 index 0000000..9622e9a --- /dev/null +++ b/backend/app/domain/produto.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class Produto: + id: int + nome: str + tipo: str + preco: float + quantidade_estoque: int + gerente_id: int diff --git a/backend/app/domain/usuario.py b/backend/app/domain/usuario.py new file mode 100644 index 0000000..24f1e93 --- /dev/null +++ b/backend/app/domain/usuario.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +PERFIL_CLIENTE = "cliente" +PERFIL_GERENTE = "gerente" +PERFIS_PERMITIDOS = frozenset({PERFIL_CLIENTE, PERFIL_GERENTE}) + + +@dataclass +class Usuario: + id: int + nome: str + email: str + login: str + senha: str + perfil: str = PERFIL_CLIENTE diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..fbf98ee --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.produto_router import router as produtos_router +from app.api.router import router as usuarios_router + +ORIGEM_FRONTEND = "http://localhost:3000" + +app = FastAPI(title="Magic Store API") + +app.add_middleware( + CORSMiddleware, + allow_origins=[ORIGEM_FRONTEND], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(usuarios_router) +app.include_router(produtos_router) + + +@app.get("/health") +def verificar_saude() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/repository/__init__.py b/backend/app/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repository/produto_repository.py b/backend/app/repository/produto_repository.py new file mode 100644 index 0000000..8c85b9e --- /dev/null +++ b/backend/app/repository/produto_repository.py @@ -0,0 +1,87 @@ +import os +import pickle +from pathlib import Path + +from app.domain.excecoes import PersistenciaError +from app.domain.produto import Produto + +ID_INICIAL = 1 + + +class ProdutoRepository: + """Repositório em memória RAM.""" + + def __init__(self) -> None: + self._produtos: dict[int, Produto] = {} + self._proximo_id = ID_INICIAL + + def salvar(self, produto: Produto) -> Produto: + self._produtos[produto.id] = produto + return produto + + def buscar_todos(self) -> list[Produto]: + return list(self._produtos.values()) + + def buscar_por_id(self, id: int) -> Produto | None: + return self._produtos.get(id) + + def remover(self, id: int) -> bool: + if id not in self._produtos: + return False + del self._produtos[id] + return True + + def contar(self) -> int: + return len(self._produtos) + + def gerar_proximo_id(self) -> int: + proximo_id = self._proximo_id + self._proximo_id += 1 + return proximo_id + + +class ProdutoArquivoBinarioRepository(ProdutoRepository): + """Repositório com persistência em arquivo binário usando pickle.""" + + def __init__(self, caminho_arquivo: str = "data/produtos.bin") -> None: + self._caminho_arquivo = Path(caminho_arquivo) + super().__init__() + self._carregar_do_arquivo() + + def salvar(self, produto: Produto) -> Produto: + produto_salvo = super().salvar(produto) + self._salvar_no_arquivo() + return produto_salvo + + def remover(self, id: int) -> bool: + removido = super().remover(id) + if removido: + self._salvar_no_arquivo() + return removido + + def _carregar_do_arquivo(self) -> None: + if not self._caminho_arquivo.exists(): + return + + try: + with self._caminho_arquivo.open("rb") as arquivo: + dados = pickle.load(arquivo) + except (OSError, pickle.PickleError, EOFError) as erro: + raise PersistenciaError("Erro ao carregar produtos do arquivo binário") from erro + + self._produtos = dados.get("produtos", {}) + self._proximo_id = dados.get("proximo_id", ID_INICIAL) + + def _salvar_no_arquivo(self) -> None: + try: + os.makedirs(self._caminho_arquivo.parent, exist_ok=True) + with self._caminho_arquivo.open("wb") as arquivo: + pickle.dump( + { + "produtos": self._produtos, + "proximo_id": self._proximo_id, + }, + arquivo, + ) + except (OSError, pickle.PickleError) as erro: + raise PersistenciaError("Erro ao salvar produtos no arquivo binário") from erro diff --git a/backend/app/repository/usuario_repository.py b/backend/app/repository/usuario_repository.py new file mode 100644 index 0000000..49eceb9 --- /dev/null +++ b/backend/app/repository/usuario_repository.py @@ -0,0 +1,89 @@ +import os +import pickle +from pathlib import Path + +from app.domain.excecoes import PersistenciaError +from app.domain.usuario import Usuario + +ID_INICIAL = 1 + + +class UsuarioRepository: + """Repositório em memória RAM.""" + + def __init__(self) -> None: + self._usuarios: dict[int, Usuario] = {} + self._proximo_id = ID_INICIAL + + def salvar(self, usuario: Usuario) -> Usuario: + self._usuarios[usuario.id] = usuario + return usuario + + def buscar_todos(self) -> list[Usuario]: + return list(self._usuarios.values()) + + def buscar_por_id(self, id: int) -> Usuario | None: + return self._usuarios.get(id) + + def buscar_por_login(self, login: str) -> Usuario | None: + login_normalizado = login.strip().lower() + for usuario in self._usuarios.values(): + if usuario.login.lower() == login_normalizado: + return usuario + return None + + def existe_email(self, email: str) -> bool: + email_normalizado = email.strip().lower() + return any( + usuario.email.lower() == email_normalizado + for usuario in self._usuarios.values() + ) + + def existe_login(self, login: str) -> bool: + return self.buscar_por_login(login) is not None + + def gerar_proximo_id(self) -> int: + proximo_id = self._proximo_id + self._proximo_id += 1 + return proximo_id + + +class UsuarioArquivoBinarioRepository(UsuarioRepository): + """Repositório com persistência em arquivo binário usando pickle.""" + + def __init__(self, caminho_arquivo: str = "data/usuarios.bin") -> None: + self._caminho_arquivo = Path(caminho_arquivo) + super().__init__() + self._carregar_do_arquivo() + + def salvar(self, usuario: Usuario) -> Usuario: + usuario_salvo = super().salvar(usuario) + self._salvar_no_arquivo() + return usuario_salvo + + def _carregar_do_arquivo(self) -> None: + if not self._caminho_arquivo.exists(): + return + + try: + with self._caminho_arquivo.open("rb") as arquivo: + dados = pickle.load(arquivo) + except (OSError, pickle.PickleError, EOFError) as erro: + raise PersistenciaError("Erro ao carregar usuários do arquivo binário") from erro + + self._usuarios = dados.get("usuarios", {}) + self._proximo_id = dados.get("proximo_id", ID_INICIAL) + + def _salvar_no_arquivo(self) -> None: + try: + os.makedirs(self._caminho_arquivo.parent, exist_ok=True) + with self._caminho_arquivo.open("wb") as arquivo: + pickle.dump( + { + "usuarios": self._usuarios, + "proximo_id": self._proximo_id, + }, + arquivo, + ) + except (OSError, pickle.PickleError) as erro: + raise PersistenciaError("Erro ao salvar usuários no arquivo binário") from erro diff --git a/backend/app/service/__init__.py b/backend/app/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/service/facade_controller.py b/backend/app/service/facade_controller.py new file mode 100644 index 0000000..b523f4e --- /dev/null +++ b/backend/app/service/facade_controller.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os + +from app.domain.produto import Produto +from app.domain.usuario import PERFIL_CLIENTE, PERFIL_GERENTE, Usuario +from app.repository.produto_repository import ( + ProdutoArquivoBinarioRepository, + ProdutoRepository, +) +from app.repository.usuario_repository import ( + UsuarioArquivoBinarioRepository, + UsuarioRepository, +) +from app.service.gerenciador_produtos import GerenciadorProdutos +from app.service.gerenciador_usuarios import GerenciadorUsuarios + + +def _criar_repositorio_usuarios() -> UsuarioRepository: + if os.getenv("WIZARDRY_PERSISTENCIA", "memoria").lower() == "arquivo": + return UsuarioArquivoBinarioRepository() + return UsuarioRepository() + + +def _criar_repositorio_produtos() -> ProdutoRepository: + if os.getenv("WIZARDRY_PERSISTENCIA", "memoria").lower() == "arquivo": + return ProdutoArquivoBinarioRepository() + return ProdutoRepository() + + +class FacadeSingletonController: + """Fachada única (Facade) e ponto de acesso único (Singleton) do Gerente. + + Concentra os controllers (`GerenciadorUsuarios` e `GerenciadorProdutos`) + atrás de uma interface única, para que a camada de boundary converse com + um só objeto. É um Singleton: `obter_instancia()` sempre devolve a mesma + instância compartilhada por toda a aplicação. + """ + + _instancia: FacadeSingletonController | None = None + + def __new__(cls) -> FacadeSingletonController: + if cls._instancia is None: + instancia = super().__new__(cls) + instancia._inicializar() + cls._instancia = instancia + return cls._instancia + + def _inicializar(self) -> None: + repositorio_usuarios = _criar_repositorio_usuarios() + repositorio_produtos = _criar_repositorio_produtos() + self._gerenciador_usuarios = GerenciadorUsuarios(repositorio_usuarios) + self._gerenciador_produtos = GerenciadorProdutos( + repositorio_produtos, + repositorio_usuarios, + ) + + @classmethod + def obter_instancia(cls) -> FacadeSingletonController: + return cls() + + @classmethod + def resetar_instancia(cls) -> None: + """Descarta o Singleton (útil em testes).""" + cls._instancia = None + + # ------------------------------------------------------------------ + # Subsistema de Usuários + # ------------------------------------------------------------------ + def cadastrar_usuario(self, nome: str, email: str, login: str, senha: str) -> Usuario: + return self._gerenciador_usuarios.adicionar_usuario( + nome, email, login, senha, PERFIL_CLIENTE + ) + + def cadastrar_gerente(self, nome: str, email: str, login: str, senha: str) -> Usuario: + return self._gerenciador_usuarios.adicionar_usuario( + nome, email, login, senha, PERFIL_GERENTE + ) + + def autenticar_usuario(self, login: str, senha: str) -> Usuario: + return self._gerenciador_usuarios.autenticar_usuario(login, senha) + + def listar_usuarios(self) -> list[Usuario]: + return self._gerenciador_usuarios.listar_usuarios() + + # ------------------------------------------------------------------ + # Subsistema de Produtos (CRUD gerenciado pelo Gerente) + # ------------------------------------------------------------------ + def cadastrar_produto( + self, + nome: str, + tipo: str, + preco: float, + quantidade_estoque: int, + gerente_id: int, + ) -> Produto: + return self._gerenciador_produtos.adicionar_produto( + nome, tipo, preco, quantidade_estoque, gerente_id + ) + + def listar_produtos(self) -> list[Produto]: + return self._gerenciador_produtos.listar_produtos() + + def buscar_produto(self, id: int) -> Produto: + return self._gerenciador_produtos.buscar_produto(id) + + def atualizar_produto( + self, + id: int, + nome: str, + tipo: str, + preco: float, + quantidade_estoque: int, + ) -> Produto: + return self._gerenciador_produtos.atualizar_produto( + id, nome, tipo, preco, quantidade_estoque + ) + + def remover_produto(self, id: int) -> None: + self._gerenciador_produtos.remover_produto(id) + + # ------------------------------------------------------------------ + # Estatísticas do sistema + # ------------------------------------------------------------------ + def quantidade_entidades_cadastradas(self) -> int: + """Total de entidades cadastradas (usuários + produtos).""" + return len(self._gerenciador_usuarios.listar_usuarios()) + ( + self._gerenciador_produtos.contar_produtos() + ) diff --git a/backend/app/service/gerenciador_produtos.py b/backend/app/service/gerenciador_produtos.py new file mode 100644 index 0000000..1d46c87 --- /dev/null +++ b/backend/app/service/gerenciador_produtos.py @@ -0,0 +1,119 @@ +from app.domain.excecoes import ( + DadosInvalidosError, + ProdutoNaoEncontradoError, + UsuarioNaoEncontradoError, +) +from app.domain.produto import Produto +from app.domain.usuario import PERFIL_GERENTE +from app.repository.produto_repository import ProdutoRepository +from app.repository.usuario_repository import UsuarioRepository + +TIPOS_PERMITIDOS = frozenset({"carta", "booster", "deck", "acessorio"}) + + +class GerenciadorProdutos: + """Camada de controle do CRUD de Produto (usado pelo Gerente).""" + + def __init__( + self, + repositorio: ProdutoRepository, + repositorio_usuarios: UsuarioRepository, + ) -> None: + self._repositorio = repositorio + self._repositorio_usuarios = repositorio_usuarios + + def adicionar_produto( + self, + nome: str, + tipo: str, + preco: float, + quantidade_estoque: int, + gerente_id: int, + ) -> Produto: + nome_tratado = nome.strip() + tipo_tratado = tipo.strip().lower() + + self._validar_nome(nome_tratado) + self._validar_tipo(tipo_tratado) + self._validar_preco(preco) + self._validar_estoque(quantidade_estoque) + self._validar_gerente(gerente_id) + + produto = Produto( + id=self._repositorio.gerar_proximo_id(), + nome=nome_tratado, + tipo=tipo_tratado, + preco=preco, + quantidade_estoque=quantidade_estoque, + gerente_id=gerente_id, + ) + return self._repositorio.salvar(produto) + + def listar_produtos(self) -> list[Produto]: + return self._repositorio.buscar_todos() + + def buscar_produto(self, id: int) -> Produto: + produto = self._repositorio.buscar_por_id(id) + if produto is None: + raise ProdutoNaoEncontradoError("Produto não encontrado") + return produto + + def atualizar_produto( + self, + id: int, + nome: str, + tipo: str, + preco: float, + quantidade_estoque: int, + ) -> Produto: + produto = self.buscar_produto(id) + + nome_tratado = nome.strip() + tipo_tratado = tipo.strip().lower() + + self._validar_nome(nome_tratado) + self._validar_tipo(tipo_tratado) + self._validar_preco(preco) + self._validar_estoque(quantidade_estoque) + + produto.nome = nome_tratado + produto.tipo = tipo_tratado + produto.preco = preco + produto.quantidade_estoque = quantidade_estoque + return self._repositorio.salvar(produto) + + def remover_produto(self, id: int) -> None: + if not self._repositorio.remover(id): + raise ProdutoNaoEncontradoError("Produto não encontrado") + + def contar_produtos(self) -> int: + return self._repositorio.contar() + + def _validar_nome(self, nome: str) -> None: + if not nome: + raise DadosInvalidosError("Nome do produto não pode ser vazio") + + def _validar_tipo(self, tipo: str) -> None: + if tipo not in TIPOS_PERMITIDOS: + raise DadosInvalidosError( + "Tipo inválido. Use: carta, booster, deck ou acessorio" + ) + + def _validar_preco(self, preco: float) -> None: + if preco <= 0: + raise DadosInvalidosError("Preço deve ser maior que zero") + + def _validar_estoque(self, quantidade_estoque: int) -> None: + if quantidade_estoque < 0: + raise DadosInvalidosError("Quantidade em estoque não pode ser negativa") + + def _validar_gerente(self, gerente_id: int) -> None: + gerente = self._repositorio_usuarios.buscar_por_id(gerente_id) + if gerente is None: + raise UsuarioNaoEncontradoError( + "Gerente responsável pelo produto não existe" + ) + if gerente.perfil != PERFIL_GERENTE: + raise DadosInvalidosError( + "Somente usuários com perfil de gerente podem cadastrar produtos" + ) diff --git a/backend/app/service/gerenciador_usuarios.py b/backend/app/service/gerenciador_usuarios.py new file mode 100644 index 0000000..4c45bd0 --- /dev/null +++ b/backend/app/service/gerenciador_usuarios.py @@ -0,0 +1,108 @@ +from app.domain.excecoes import ( + CredenciaisInvalidasError, + DadosInvalidosError, + EmailDuplicadoError, + LoginDuplicadoError, +) +from app.domain.usuario import PERFIL_CLIENTE, PERFIS_PERMITIDOS, Usuario +from app.repository.usuario_repository import UsuarioRepository + +CARACTERES_ESPECIAIS_AWS = set("!@#$%^&*()_+-=[]{}|'") +TAMANHO_MINIMO_SENHA_AWS = 8 +TAMANHO_MAXIMO_SENHA_AWS = 128 +TAMANHO_MAXIMO_LOGIN = 12 + + +class GerenciadorUsuarios: + def __init__(self, repositorio: UsuarioRepository) -> None: + self._repositorio = repositorio + + def adicionar_usuario( + self, + nome: str, + email: str, + login: str, + senha: str, + perfil: str = PERFIL_CLIENTE, + ) -> Usuario: + nome_tratado = nome.strip() + email_tratado = email.strip() + login_tratado = login.strip() + perfil_tratado = perfil.strip().lower() + + self._validar_nome(nome_tratado) + self._validar_email(email_tratado) + self._validar_login(login_tratado) + self._validar_senha(senha) + self._validar_perfil(perfil_tratado) + + if self._repositorio.existe_email(email_tratado): + raise EmailDuplicadoError("E-mail já cadastrado") + + if self._repositorio.existe_login(login_tratado): + raise LoginDuplicadoError("Login já cadastrado") + + usuario = Usuario( + id=self._repositorio.gerar_proximo_id(), + nome=nome_tratado, + email=email_tratado, + login=login_tratado, + senha=senha, + perfil=perfil_tratado, + ) + return self._repositorio.salvar(usuario) + + def autenticar_usuario(self, login: str, senha: str) -> Usuario: + login_tratado = login.strip() + + if not login_tratado or not senha: + raise CredenciaisInvalidasError("Login e senha são obrigatórios") + + usuario = self._repositorio.buscar_por_login(login_tratado) + if usuario is None or usuario.senha != senha: + raise CredenciaisInvalidasError("Login ou senha inválidos") + + return usuario + + def listar_usuarios(self) -> list[Usuario]: + return self._repositorio.buscar_todos() + + def _validar_nome(self, nome: str) -> None: + if not nome: + raise DadosInvalidosError("Nome não pode ser vazio") + + def _validar_perfil(self, perfil: str) -> None: + if perfil not in PERFIS_PERMITIDOS: + raise DadosInvalidosError("Perfil inválido. Use: cliente ou gerente") + + def _validar_email(self, email: str) -> None: + if not email or "@" not in email: + raise DadosInvalidosError("E-mail inválido") + + def _validar_login(self, login: str) -> None: + if not login: + raise DadosInvalidosError("Login não pode ser vazio") + + if len(login) > TAMANHO_MAXIMO_LOGIN: + raise DadosInvalidosError("Login deve ter no máximo 12 caracteres") + + if any(caractere.isdigit() for caractere in login): + raise DadosInvalidosError("Login não pode conter números") + + def _validar_senha(self, senha: str) -> None: + if len(senha) < TAMANHO_MINIMO_SENHA_AWS: + raise DadosInvalidosError("Senha deve ter no mínimo 8 caracteres") + + if len(senha) > TAMANHO_MAXIMO_SENHA_AWS: + raise DadosInvalidosError("Senha deve ter no máximo 128 caracteres") + + quantidade_grupos = 0 + quantidade_grupos += any(caractere.isupper() for caractere in senha) + quantidade_grupos += any(caractere.islower() for caractere in senha) + quantidade_grupos += any(caractere.isdigit() for caractere in senha) + quantidade_grupos += any(caractere in CARACTERES_ESPECIAIS_AWS for caractere in senha) + + if quantidade_grupos < 3: + raise DadosInvalidosError( + "Senha deve conter pelo menos 3 grupos: maiúsculas, minúsculas, números e caracteres especiais" + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..8fa31f6 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "magic-store-backend" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic[email]>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "httpx>=0.27", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/backend/tests/test_facade_controller.py b/backend/tests/test_facade_controller.py new file mode 100644 index 0000000..c7a9caf --- /dev/null +++ b/backend/tests/test_facade_controller.py @@ -0,0 +1,62 @@ +import pytest + +from app.service.facade_controller import FacadeSingletonController + + +@pytest.fixture +def facade() -> FacadeSingletonController: + FacadeSingletonController.resetar_instancia() + return FacadeSingletonController.obter_instancia() + + +def test_deve_ser_singleton() -> None: + FacadeSingletonController.resetar_instancia() + + primeira = FacadeSingletonController.obter_instancia() + segunda = FacadeSingletonController.obter_instancia() + terceira = FacadeSingletonController() + + assert primeira is segunda + assert primeira is terceira + + +def test_quantidade_entidades_inicia_em_zero( + facade: FacadeSingletonController, +) -> None: + assert facade.quantidade_entidades_cadastradas() == 0 + + +def test_quantidade_entidades_soma_usuarios_e_produtos( + facade: FacadeSingletonController, +) -> None: + gerente = facade.cadastrar_gerente("Ana", "ana@email.com", "anagerente", "Senha123") + facade.cadastrar_produto("Black Lotus", "carta", 50000.0, 1, gerente.id) + facade.cadastrar_produto("Booster", "booster", 25.0, 10, gerente.id) + + # 1 usuário + 2 produtos + assert facade.quantidade_entidades_cadastradas() == 3 + + +def test_facade_diferencia_cliente_de_gerente( + facade: FacadeSingletonController, +) -> None: + cliente = facade.cadastrar_usuario("Bruno", "bruno@email.com", "brunocli", "Senha123") + gerente = facade.cadastrar_gerente("Ana", "ana@email.com", "anagerente", "Senha123") + + assert cliente.perfil == "cliente" + assert gerente.perfil == "gerente" + + +def test_facade_orquestra_crud_de_produto( + facade: FacadeSingletonController, +) -> None: + gerente = facade.cadastrar_gerente("Ana", "ana@email.com", "anagerente", "Senha123") + + produto = facade.cadastrar_produto("Deck Azul", "deck", 100.0, 5, gerente.id) + facade.atualizar_produto(produto.id, "Deck Vermelho", "deck", 120.0, 3) + assert facade.buscar_produto(produto.id).nome == "Deck Vermelho" + + facade.remover_produto(produto.id) + assert facade.listar_produtos() == [] + # sobra apenas o usuário + assert facade.quantidade_entidades_cadastradas() == 1 diff --git a/backend/tests/test_gerenciador_produtos.py b/backend/tests/test_gerenciador_produtos.py new file mode 100644 index 0000000..1cbf4b6 --- /dev/null +++ b/backend/tests/test_gerenciador_produtos.py @@ -0,0 +1,168 @@ +import pytest + +from app.domain.excecoes import ( + DadosInvalidosError, + ProdutoNaoEncontradoError, + UsuarioNaoEncontradoError, +) +from app.repository.produto_repository import ( + ProdutoArquivoBinarioRepository, + ProdutoRepository, +) +from app.domain.usuario import PERFIL_GERENTE +from app.repository.usuario_repository import UsuarioRepository +from app.service.gerenciador_produtos import GerenciadorProdutos +from app.service.gerenciador_usuarios import GerenciadorUsuarios + + +@pytest.fixture +def repositorio_usuarios() -> UsuarioRepository: + return UsuarioRepository() + + +@pytest.fixture +def gerente_id(repositorio_usuarios: UsuarioRepository) -> int: + gerenciador_usuarios = GerenciadorUsuarios(repositorio_usuarios) + gerente = gerenciador_usuarios.adicionar_usuario( + "Ana Gerente", "ana@email.com", "anagerente", "Senha123", PERFIL_GERENTE + ) + return gerente.id + + +@pytest.fixture +def gerenciador(repositorio_usuarios: UsuarioRepository) -> GerenciadorProdutos: + return GerenciadorProdutos(ProdutoRepository(), repositorio_usuarios) + + +def test_deve_retornar_lista_vazia_quando_nao_houver_produtos( + gerenciador: GerenciadorProdutos, +) -> None: + assert gerenciador.listar_produtos() == [] + + +def test_deve_adicionar_produto_com_sucesso( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + produto = gerenciador.adicionar_produto("Black Lotus", "Carta", 50000.0, 1, gerente_id) + + produtos = gerenciador.listar_produtos() + + assert len(produtos) == 1 + assert produtos[0].id == produto.id + assert produtos[0].nome == "Black Lotus" + assert produtos[0].tipo == "carta" + assert produtos[0].preco == 50000.0 + assert produtos[0].quantidade_estoque == 1 + assert produtos[0].gerente_id == gerente_id + + +def test_deve_rejeitar_produto_de_gerente_inexistente( + gerenciador: GerenciadorProdutos, +) -> None: + with pytest.raises(UsuarioNaoEncontradoError): + gerenciador.adicionar_produto("Booster", "booster", 25.0, 10, 999) + + +def test_deve_rejeitar_produto_cadastrado_por_cliente( + gerenciador: GerenciadorProdutos, repositorio_usuarios: UsuarioRepository +) -> None: + gerenciador_usuarios = GerenciadorUsuarios(repositorio_usuarios) + cliente = gerenciador_usuarios.adicionar_usuario( + "Bruno Cliente", "bruno@email.com", "brunocli", "Senha123" + ) + + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_produto("Booster", "booster", 25.0, 10, cliente.id) + + +def test_deve_rejeitar_produto_com_nome_vazio( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_produto(" ", "carta", 10.0, 1, gerente_id) + + +def test_deve_rejeitar_produto_com_tipo_invalido( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_produto("Item", "invalido", 10.0, 1, gerente_id) + + +def test_deve_rejeitar_produto_com_preco_nao_positivo( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_produto("Item", "carta", 0.0, 1, gerente_id) + + +def test_deve_rejeitar_produto_com_estoque_negativo( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_produto("Item", "carta", 10.0, -1, gerente_id) + + +def test_deve_buscar_produto_por_id( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + produto = gerenciador.adicionar_produto("Deck Azul", "deck", 100.0, 5, gerente_id) + + encontrado = gerenciador.buscar_produto(produto.id) + + assert encontrado.id == produto.id + + +def test_deve_lancar_erro_ao_buscar_produto_inexistente( + gerenciador: GerenciadorProdutos, +) -> None: + with pytest.raises(ProdutoNaoEncontradoError): + gerenciador.buscar_produto(999) + + +def test_deve_atualizar_produto( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + produto = gerenciador.adicionar_produto("Deck Azul", "deck", 100.0, 5, gerente_id) + + atualizado = gerenciador.atualizar_produto(produto.id, "Deck Vermelho", "deck", 120.0, 3) + + assert atualizado.nome == "Deck Vermelho" + assert atualizado.preco == 120.0 + assert atualizado.quantidade_estoque == 3 + assert gerenciador.buscar_produto(produto.id).nome == "Deck Vermelho" + + +def test_deve_remover_produto( + gerenciador: GerenciadorProdutos, gerente_id: int +) -> None: + produto = gerenciador.adicionar_produto("Booster", "booster", 25.0, 10, gerente_id) + + gerenciador.remover_produto(produto.id) + + assert gerenciador.listar_produtos() == [] + + +def test_deve_lancar_erro_ao_remover_produto_inexistente( + gerenciador: GerenciadorProdutos, +) -> None: + with pytest.raises(ProdutoNaoEncontradoError): + gerenciador.remover_produto(999) + + +def test_deve_persistir_produtos_em_arquivo_binario( + tmp_path, repositorio_usuarios: UsuarioRepository, gerente_id: int +) -> None: + caminho_arquivo = tmp_path / "produtos.bin" + repositorio = ProdutoArquivoBinarioRepository(str(caminho_arquivo)) + gerenciador = GerenciadorProdutos(repositorio, repositorio_usuarios) + + gerenciador.adicionar_produto("Black Lotus", "carta", 50000.0, 1, gerente_id) + + novo_repositorio = ProdutoArquivoBinarioRepository(str(caminho_arquivo)) + novo_gerenciador = GerenciadorProdutos(novo_repositorio, repositorio_usuarios) + + produtos = novo_gerenciador.listar_produtos() + + assert len(produtos) == 1 + assert produtos[0].nome == "Black Lotus" diff --git a/backend/tests/test_gerenciador_usuarios.py b/backend/tests/test_gerenciador_usuarios.py new file mode 100644 index 0000000..5dc2a22 --- /dev/null +++ b/backend/tests/test_gerenciador_usuarios.py @@ -0,0 +1,123 @@ +import pytest + +from app.domain.excecoes import DadosInvalidosError, EmailDuplicadoError, LoginDuplicadoError +from app.repository.usuario_repository import UsuarioArquivoBinarioRepository, UsuarioRepository +from app.service.gerenciador_usuarios import GerenciadorUsuarios + + +@pytest.fixture +def gerenciador() -> GerenciadorUsuarios: + return GerenciadorUsuarios(UsuarioRepository()) + + +def test_deve_retornar_lista_vazia_quando_nao_houver_usuarios( + gerenciador: GerenciadorUsuarios, +) -> None: + assert gerenciador.listar_usuarios() == [] + + +def test_deve_adicionar_usuario_com_sucesso(gerenciador: GerenciadorUsuarios) -> None: + usuario = gerenciador.adicionar_usuario( + "Ana Silva", + "ana@email.com", + "anasilva", + "Senha123", + ) + + usuarios = gerenciador.listar_usuarios() + + assert len(usuarios) == 1 + assert usuarios[0].id == usuario.id + assert usuarios[0].nome == "Ana Silva" + assert usuarios[0].email == "ana@email.com" + assert usuarios[0].login == "anasilva" + assert usuarios[0].senha == "Senha123" + assert usuarios[0].perfil == "cliente" + + +def test_deve_cadastrar_usuario_com_perfil_gerente( + gerenciador: GerenciadorUsuarios, +) -> None: + gerente = gerenciador.adicionar_usuario( + "Ana Silva", "ana@email.com", "anasilva", "Senha123", "gerente" + ) + + assert gerente.perfil == "gerente" + + +def test_deve_rejeitar_usuario_com_nome_vazio(gerenciador: GerenciadorUsuarios) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario(" ", "ana@email.com", "anasilva", "Senha123") + + +def test_deve_rejeitar_usuario_com_email_duplicado( + gerenciador: GerenciadorUsuarios, +) -> None: + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", "Senha123") + + with pytest.raises(EmailDuplicadoError): + gerenciador.adicionar_usuario("Bruno Costa", "ana@email.com", "bruno", "Senha123") + + +def test_deve_rejeitar_login_vazio(gerenciador: GerenciadorUsuarios) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", " ", "Senha123") + + +def test_deve_rejeitar_login_com_mais_de_12_caracteres( + gerenciador: GerenciadorUsuarios, +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "loginmuitogrande", "Senha123") + + +def test_deve_rejeitar_login_com_numero(gerenciador: GerenciadorUsuarios) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "ana1", "Senha123") + + +def test_deve_rejeitar_login_duplicado(gerenciador: GerenciadorUsuarios) -> None: + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", "Senha123") + + with pytest.raises(LoginDuplicadoError): + gerenciador.adicionar_usuario("Bruno Costa", "bruno@email.com", "anasilva", "Senha123") + + +def test_deve_rejeitar_senha_com_menos_de_8_caracteres( + gerenciador: GerenciadorUsuarios, +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", "Sen123") + + +def test_deve_rejeitar_senha_com_mais_de_128_caracteres( + gerenciador: GerenciadorUsuarios, +) -> None: + senha = "A" * 129 + + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", senha) + + +def test_deve_rejeitar_senha_com_menos_de_3_grupos_aws( + gerenciador: GerenciadorUsuarios, +) -> None: + with pytest.raises(DadosInvalidosError): + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", "senhasimples") + + +def test_deve_persistir_usuarios_em_arquivo_binario(tmp_path) -> None: + caminho_arquivo = tmp_path / "usuarios.bin" + repositorio = UsuarioArquivoBinarioRepository(str(caminho_arquivo)) + gerenciador = GerenciadorUsuarios(repositorio) + + gerenciador.adicionar_usuario("Ana Silva", "ana@email.com", "anasilva", "Senha123") + + novo_repositorio = UsuarioArquivoBinarioRepository(str(caminho_arquivo)) + novo_gerenciador = GerenciadorUsuarios(novo_repositorio) + + usuarios = novo_gerenciador.listar_usuarios() + + assert len(usuarios) == 1 + assert usuarios[0].nome == "Ana Silva" + assert usuarios[0].login == "anasilva" diff --git a/diagrama_classes_bce_loja_magic_2_atores_campos.html b/diagrama_classes_bce_loja_magic_2_atores_campos.html index a123697..1fe24aa 100644 --- a/diagrama_classes_bce_loja_magic_2_atores_campos.html +++ b/diagrama_classes_bce_loja_magic_2_atores_campos.html @@ -11,11 +11,11 @@ mermaid.initialize({ startOnLoad: false, theme: 'base', - fontFamily: '"Anthropic Sans", sans-serif', + fontFamily: 'Arial, sans-serif', themeVariables: { darkMode: dark, fontSize: '13px', - fontFamily: '"Anthropic Sans", sans-serif', + fontFamily: 'Arial, sans-serif', lineColor: dark ? '#9c9a92' : '#73726c', textColor: dark ? '#c2c0b6' : '#3d3d3a', primaryColor: dark ? '#3C3489' : '#EEEDFE', @@ -25,64 +25,128 @@ }); const src = `classDiagram direction LR + class Cliente { <> - +int id - +String nome - +String email - +String endereco + +acessarSistema() + +fazerLogin() + +fazerCadastro() } + class Administrador { <> - +int id - +String nome - +String login - +String senha + +acessarSistema() + +fazerLogin() } - class TelaCompra { + + class TelaInicial { <> - +finalizarCompra() + +abrirLogin() } + + class TelaLogin { + <> + +informarLogin() + +informarSenha() + +entrar() + +abrirCadastro() + } + + class TelaCadastroUsuario { + <> + +informarNome() + +informarEmail() + +informarLogin() + +informarSenha() + +cadastrar() + } + class PainelAdmin { <> +cadastrarProduto() +listarPedidos() } - class ControleCompra { + + class ControleUsuario { <> - +processarCompra() + -UsuarioRepository repositorio + +adicionarUsuario(nome, email, login, senha) + +autenticarUsuario(login, senha) + +listarUsuarios() + -validarLogin(login) + -validarSenha(senha) } - class ControleAdmin { - <> - +gerenciarProduto() - +revisarPedido() + + class Usuario { + <> + +int id + +String nome + +String email + +String login + +String senha + +String perfil } - class Pedido { + + class UsuarioRepository { <> - +Date data - +String status - +float total + +salvar(usuario) + +buscarTodos() + +buscarPorLogin(login) + +existeEmail(email) + +existeLogin(login) + +gerarProximoId() } - class Produto { + + class UsuarioMemoriaRepository { <> - +String nome - +float preco - +int estoque + -Map usuarios } - class Pagamento { + + class UsuarioArquivoBinarioRepository { <> - +String metodo - +String status - } - Cliente --> TelaCompra - Administrador --> PainelAdmin - TelaCompra --> ControleCompra - PainelAdmin --> ControleAdmin - ControleCompra --> Pedido - ControleAdmin --> Produto - ControleAdmin --> Pedido - Pedido "*" --> "*" Produto : contem - Pedido "1" --> "1" Pagamento : gera`; + -String caminhoArquivo + +carregarDoArquivo() + +salvarNoArquivo() + } + + class DadosInvalidosError { + <> + } + + class EmailDuplicadoError { + <> + } + + class LoginDuplicadoError { + <> + } + + class CredenciaisInvalidasError { + <> + } + + class PersistenciaError { + <> + } + + Cliente --> TelaInicial : acessa + Administrador --> TelaInicial : acessa + TelaInicial --> TelaLogin : botão Login + TelaLogin --> TelaCadastroUsuario : botão Cadastro + TelaLogin --> ControleUsuario : autenticar + TelaCadastroUsuario --> ControleUsuario : cadastrar cliente + ControleUsuario --> Usuario : cria/autentica + ControleUsuario --> UsuarioRepository : usa + UsuarioRepository <|-- UsuarioMemoriaRepository + UsuarioRepository <|-- UsuarioArquivoBinarioRepository + UsuarioRepository --> Usuario : armazena + UsuarioArquivoBinarioRepository ..> PersistenciaError : IOException + ControleUsuario ..> DadosInvalidosError : login/senha inválidos + ControleUsuario ..> EmailDuplicadoError : e-mail duplicado + ControleUsuario ..> LoginDuplicadoError : login duplicado + ControleUsuario ..> CredenciaisInvalidasError : credenciais inválidas + Administrador --> PainelAdmin : após login + PainelAdmin --> ControleUsuario : listar usuários`; const { svg } = await mermaid.render('cd-svg', src); document.getElementById('cd').innerHTML = svg; diff --git a/diagrama_facade_singleton_sprint3.html b/diagrama_facade_singleton_sprint3.html new file mode 100644 index 0000000..072b999 --- /dev/null +++ b/diagrama_facade_singleton_sprint3.html @@ -0,0 +1,160 @@ + +
+ \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..600de8d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b249e4b --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.env*.local +*.tsbuildinfo diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..4e28814 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,7 @@ +export default function LayoutPainelAdmin({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..77dc71f --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,336 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { + contarEntidades, + criarGerente, + criarProduto, + listarProdutos, + listarUsuarios, + removerProduto, + type Produto, + type Usuario, +} from "@/lib/api"; + +const TIPOS_PRODUTO = ["carta", "booster", "deck", "acessorio"]; + +export default function PaginaPainelAdmin() { + const [gerentes, setGerentes] = useState([]); + const [produtos, setProdutos] = useState([]); + const [quantidadeEntidades, setQuantidadeEntidades] = useState(0); + const [mensagemErro, setMensagemErro] = useState(null); + const [mensagemSucesso, setMensagemSucesso] = useState(null); + + // formulário de gerente + const [nome, setNome] = useState(""); + const [email, setEmail] = useState(""); + const [login, setLogin] = useState(""); + const [senha, setSenha] = useState(""); + + // formulário de produto + const [nomeProduto, setNomeProduto] = useState(""); + const [tipoProduto, setTipoProduto] = useState(TIPOS_PRODUTO[0]); + const [precoProduto, setPrecoProduto] = useState(""); + const [estoqueProduto, setEstoqueProduto] = useState(""); + const [gerenteId, setGerenteId] = useState(""); + + const carregarDados = useCallback(async () => { + setMensagemErro(null); + try { + const [usuarios, listaProdutos, quantidade] = await Promise.all([ + listarUsuarios(), + listarProdutos(), + contarEntidades(), + ]); + setGerentes(usuarios.filter((usuario) => usuario.perfil === "gerente")); + setProdutos(listaProdutos); + setQuantidadeEntidades(quantidade); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao carregar dados", + ); + } + }, []); + + useEffect(() => { + void carregarDados(); + }, [carregarDados]); + + async function handleCadastrarGerente(evento: FormEvent) { + evento.preventDefault(); + setMensagemErro(null); + setMensagemSucesso(null); + + try { + await criarGerente(nome, email, login, senha); + setNome(""); + setEmail(""); + setLogin(""); + setSenha(""); + setMensagemSucesso("Gerente cadastrado com sucesso."); + await carregarDados(); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao cadastrar gerente", + ); + } + } + + async function handleCadastrarProduto(evento: FormEvent) { + evento.preventDefault(); + setMensagemErro(null); + setMensagemSucesso(null); + + try { + await criarProduto({ + nome: nomeProduto, + tipo: tipoProduto, + preco: Number(precoProduto), + quantidade_estoque: Number(estoqueProduto), + gerente_id: Number(gerenteId), + }); + setNomeProduto(""); + setPrecoProduto(""); + setEstoqueProduto(""); + setMensagemSucesso("Produto cadastrado com sucesso."); + await carregarDados(); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao cadastrar produto", + ); + } + } + + async function handleRemoverProduto(id: number) { + setMensagemErro(null); + setMensagemSucesso(null); + + try { + await removerProduto(id); + setMensagemSucesso("Produto removido com sucesso."); + await carregarDados(); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao remover produto", + ); + } + } + + return ( +
+
+
+

Wizardry

+

Painel do gerente

+

+ Entidades cadastradas no sistema:{" "} + {quantidadeEntidades} +

+
+ + {mensagemErro && ( +

+ {mensagemErro} +

+ )} + {mensagemSucesso && ( +

+ {mensagemSucesso} +

+ )} + +
+

Cadastrar gerente

+
+ + + + + +
+
+ +
+

Cadastrar produto

+ {gerentes.length === 0 ? ( +

+ Cadastre um gerente antes de adicionar produtos. +

+ ) : ( +
+ + + + + + +
+ )} +
+ +
+

Produtos cadastrados

+ {produtos.length === 0 ? ( +

Nenhum produto cadastrado.

+ ) : ( + + + + {["ID", "Nome", "Tipo", "Preço", "Estoque", "Gerente", ""].map( + (coluna) => ( + + ), + )} + + + + {produtos.map((produto) => ( + + + + + + + + + + ))} + +
+ {coluna} +
{produto.id}{produto.nome}{produto.tipo} + R$ {produto.preco.toFixed(2)} + + {produto.quantidade_estoque} + #{produto.gerente_id} + +
+ )} +
+ +
+ + Voltar para cadastro + +
+
+
+ ); +} diff --git a/frontend/app/cadastro/page.tsx b/frontend/app/cadastro/page.tsx new file mode 100644 index 0000000..c06c0c4 --- /dev/null +++ b/frontend/app/cadastro/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { criarUsuario, listarUsuarios, type Usuario } from "@/lib/api"; + +export default function PaginaCadastro() { + const [usuarios, setUsuarios] = useState([]); + const [nome, setNome] = useState(""); + const [email, setEmail] = useState(""); + const [login, setLogin] = useState(""); + const [senha, setSenha] = useState(""); + const [mensagemErro, setMensagemErro] = useState(null); + const [mensagemSucesso, setMensagemSucesso] = useState(null); + const [carregando, setCarregando] = useState(true); + + const carregarUsuarios = useCallback(async () => { + setCarregando(true); + setMensagemErro(null); + + try { + const lista = await listarUsuarios(); + setUsuarios(lista); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao carregar usuários", + ); + } finally { + setCarregando(false); + } + }, []); + + useEffect(() => { + void carregarUsuarios(); + }, [carregarUsuarios]); + + async function handleSubmit(evento: FormEvent) { + evento.preventDefault(); + setMensagemErro(null); + setMensagemSucesso(null); + + try { + await criarUsuario(nome, email, login, senha); + setNome(""); + setEmail(""); + setLogin(""); + setSenha(""); + setMensagemSucesso("Usuário cadastrado com sucesso."); + await carregarUsuarios(); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao adicionar usuário", + ); + } + } + + return ( +
+
+
+

Wizardry

+

Cadastro de usuário

+

+ O cadastro valida login, senha e salva os usuários em RAM ou arquivo binário. +

+ +
+ + + + + + + + + +
+ + {mensagemErro && ( +

+ {mensagemErro} +

+ )} + + {mensagemSucesso && ( +

+ {mensagemSucesso} +

+ )} + +
+ + Voltar para login + + + Painel do gerente + +
+
+ +
+

Usuários cadastrados

+ {carregando ? ( +

Carregando...

+ ) : usuarios.length === 0 ? ( +

Nenhum usuário cadastrado.

+ ) : ( + + + + + + + + + + + + {usuarios.map((usuario) => ( + + + + + + + + ))} + +
+ ID + + Nome + + E-mail + + Login + + Perfil +
{usuario.id}{usuario.nome}{usuario.email}{usuario.login} + {usuario.perfil === "gerente" ? ( + + Gerente + + ) : ( + Cliente + )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..0021e79 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,67 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #f5f2ff; + color: #1a1a1a; +} + +a { + color: #534ab7; + text-decoration: none; +} + +button, +.link-button { + border: 0; + border-radius: 10px; + background: #534ab7; + color: #fff; + cursor: pointer; + display: inline-block; + font: inherit; + padding: 0.7rem 1rem; + text-align: center; +} + +button:hover, +.link-button:hover { + background: #3c3489; +} + +.secondary-button { + background: #ffffff; + border: 1px solid #d8d3ff; + color: #534ab7; +} + +.secondary-button:hover { + background: #eeedfe; +} + +.card { + background: #ffffff; + border: 1px solid #e5e0ff; + border-radius: 18px; + box-shadow: 0 10px 24px rgba(38, 33, 92, 0.08); + padding: 1.5rem; +} + +.input-field { + border: 1px solid #d8d3ff; + border-radius: 10px; + display: block; + font: inherit; + margin-top: 0.25rem; + padding: 0.65rem; + width: 100%; +} + +.page-shell { + margin: 0 auto; + max-width: 760px; + padding: 2rem; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..b38183d --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Magic Store", + description: "Sistema de gestão da loja Magic", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..f6cbcd2 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useState } from "react"; +import { autenticarUsuario } from "@/lib/api"; + +export default function PaginaLogin() { + const [login, setLogin] = useState(""); + const [senha, setSenha] = useState(""); + const [mensagemErro, setMensagemErro] = useState(null); + const [mensagemSucesso, setMensagemSucesso] = useState(null); + + async function handleSubmit(evento: FormEvent) { + evento.preventDefault(); + setMensagemErro(null); + setMensagemSucesso(null); + + try { + const usuario = await autenticarUsuario(login, senha); + setMensagemSucesso(`Login realizado com sucesso. Bem-vindo(a), ${usuario.nome}.`); + setLogin(""); + setSenha(""); + } catch (erro) { + setMensagemErro( + erro instanceof Error ? erro.message : "Erro ao fazer login", + ); + } + } + + return ( +
+
+

Wizardry

+

Login

+

+ Acesse usando o login e a senha cadastrados no sistema. +

+ +
+ + + + + +
+ + {mensagemErro && ( +

+ {mensagemErro} +

+ )} + + {mensagemSucesso && ( +

+ {mensagemSucesso} +

+ )} + +
+ + Criar cadastro + +
+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..17904d6 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function PaginaInicial() { + return ( +
+
+

Wizardry

+

Magic Store

+

+ Sistema de gerenciamento para loja de Magic: The Gathering. +

+ + Ir para login + +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..3c3cff1 --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,156 @@ +export type Perfil = "cliente" | "gerente"; + +export type Usuario = { + id: number; + nome: string; + email: string; + login: string; + perfil: Perfil; +}; + +export type Produto = { + id: number; + nome: string; + tipo: string; + preco: number; + quantidade_estoque: number; + gerente_id: number; +}; + +export type QuantidadeEntidades = { + quantidade: number; +}; + +const URL_BASE_API = + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +async function obterMensagemErro(resposta: Response, mensagemPadrao: string) { + const corpo = await resposta.json().catch(() => null); + return corpo?.detail ?? mensagemPadrao; +} + +export async function listarUsuarios(): Promise { + const resposta = await fetch(`${URL_BASE_API}/usuarios`); + + if (!resposta.ok) { + throw new Error("Não foi possível carregar os usuários"); + } + + return resposta.json(); +} + +export async function criarUsuario( + nome: string, + email: string, + login: string, + senha: string, +): Promise { + const resposta = await fetch(`${URL_BASE_API}/usuarios`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nome, email, login, senha }), + }); + + if (!resposta.ok) { + throw new Error( + await obterMensagemErro(resposta, "Não foi possível adicionar o usuário"), + ); + } + + return resposta.json(); +} + +export async function criarGerente( + nome: string, + email: string, + login: string, + senha: string, +): Promise { + const resposta = await fetch(`${URL_BASE_API}/usuarios/gerentes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nome, email, login, senha }), + }); + + if (!resposta.ok) { + throw new Error( + await obterMensagemErro(resposta, "Não foi possível cadastrar o gerente"), + ); + } + + return resposta.json(); +} + +export async function autenticarUsuario( + login: string, + senha: string, +): Promise { + const resposta = await fetch(`${URL_BASE_API}/usuarios/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login, senha }), + }); + + if (!resposta.ok) { + throw new Error( + await obterMensagemErro(resposta, "Não foi possível fazer login"), + ); + } + + return resposta.json(); +} + +export async function listarProdutos(): Promise { + const resposta = await fetch(`${URL_BASE_API}/produtos`); + + if (!resposta.ok) { + throw new Error("Não foi possível carregar os produtos"); + } + + return resposta.json(); +} + +export async function criarProduto(dados: { + nome: string; + tipo: string; + preco: number; + quantidade_estoque: number; + gerente_id: number; +}): Promise { + const resposta = await fetch(`${URL_BASE_API}/produtos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(dados), + }); + + if (!resposta.ok) { + throw new Error( + await obterMensagemErro(resposta, "Não foi possível cadastrar o produto"), + ); + } + + return resposta.json(); +} + +export async function removerProduto(id: number): Promise { + const resposta = await fetch(`${URL_BASE_API}/produtos/${id}`, { + method: "DELETE", + }); + + if (!resposta.ok) { + throw new Error( + await obterMensagemErro(resposta, "Não foi possível remover o produto"), + ); + } +} + +export async function contarEntidades(): Promise { + const resposta = await fetch(`${URL_BASE_API}/produtos/quantidade`); + + if (!resposta.ok) { + throw new Error("Não foi possível carregar a quantidade de entidades"); + } + + const dados: QuantidadeEntidades = await resposta.json(); + return dados.quantidade; +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..41eb269 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +// Avoid importing Next types to prevent "Cannot find module 'next'" when +// @types/next is not installed in the environment. +const nextConfig = { + allowedDevOrigins: ["http://172.17.16.1:3001"], +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6d021d8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,963 @@ +{ + "name": "magic-store-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magic-store-frontend", + "version": "0.1.0", + "dependencies": { + "next": "^15.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.5", + "typescript": "^5.8.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz", + "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.19", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.19", + "@next/swc-darwin-x64": "15.5.19", + "@next/swc-linux-arm64-gnu": "15.5.19", + "@next/swc-linux-arm64-musl": "15.5.19", + "@next/swc-linux-x64-gnu": "15.5.19", + "@next/swc-linux-x64-musl": "15.5.19", + "@next/swc-win32-arm64-msvc": "15.5.19", + "@next/swc-win32-x64-msvc": "15.5.19", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..86f4018 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "magic-store-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^15.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.5", + "typescript": "^5.8.3" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6420eed --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}