feat: add City10 platform with Laravel 13, AI agents, 3D viewer, and LGPD compliance
Initial commit of the complete City10 virtual city simulation platform. Backend (Laravel 13): - REST API with Sanctum tokens and session-based web authentication - Role-based access control (admin/user) with middleware protection - AI agent CRUD with OpenRouter.ai integration restricted to free models - Store management with 3D coordinates and AI shopkeeper assignment - Versioned legal documents (Terms, Privacy, Cookies) with LGPD audit trail - Math CAPTCHA on public forms and multi-language support (pt_BR, en, es) Frontend: - Responsive Blade templates with Tailwind CSS v4 - Three.js 3D city viewer with SSE chat streaming to AI agents - Admin panel for store and user administration Infrastructure: - MariaDB/MySQL database with comprehensive migrations - Database seeders for demo content and test accounts - PHPUnit test suite and Laravel Pint code styling - Vite build configuration with hot reload
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{compose,docker-compose}.{yml,yaml}]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
APP_NAME=City10
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:16980
|
||||||
|
|
||||||
|
APP_LOCALE=pt_BR
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=pt_BR
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=mariadb
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=laravel
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="no-reply@syspanel.com.br"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# OpenRouter.ai — keep the key in .env only, never commit it.
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.codex
|
||||||
|
/.cursor/
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/fonts-manifest.dev.json
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
_ide_helper.php
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
# Laravel Application
|
||||||
|
|
||||||
|
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Verify that PHP and Composer are available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php -v
|
||||||
|
composer -V
|
||||||
|
```
|
||||||
|
|
||||||
|
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
||||||
|
|
||||||
|
macOS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
||||||
|
|
||||||
|
## Agent Setup
|
||||||
|
|
||||||
|
Install Laravel Boost from the application root before making application changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
composer require laravel/boost --dev
|
||||||
|
php artisan boost:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
# Laravel Application
|
||||||
|
|
||||||
|
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Verify that PHP and Composer are available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php -v
|
||||||
|
composer -V
|
||||||
|
```
|
||||||
|
|
||||||
|
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
||||||
|
|
||||||
|
macOS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
||||||
|
|
||||||
|
## Agent Setup
|
||||||
|
|
||||||
|
Install Laravel Boost from the application root before making application changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
composer require laravel/boost --dev
|
||||||
|
php artisan boost:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
LICENÇA PROPRIETÁRIA — CITY10
|
||||||
|
=============================
|
||||||
|
|
||||||
|
Copyright (c) 2026 Syspanel — Santa Rita do Sapucaí/MG, Brasil
|
||||||
|
Todos os direitos reservados.
|
||||||
|
Contato: contato@syspanel.com.br — https://syspanel.com.br
|
||||||
|
|
||||||
|
1. DEFINIÇÕES
|
||||||
|
-------------
|
||||||
|
"Licenciante" : Syspanel, titular dos direitos patrimoniais sobre o Software.
|
||||||
|
"Licenciado" : pessoa física ou jurídica autorizada a utilizar o Software.
|
||||||
|
"Software" : o sistema City10, incluindo código-fonte, banco de dados,
|
||||||
|
documentação, marca, interface e quaisquer work derivados.
|
||||||
|
|
||||||
|
2. CONCESSÃO DA LICENÇA
|
||||||
|
-----------------------
|
||||||
|
O Licenciante concede ao Licenciado uma licença limitada, não exclusiva,
|
||||||
|
não transferível e revogável para utilizar o Software exclusivamente para
|
||||||
|
fins internos ou de avaliação, nos termos deste documento.
|
||||||
|
|
||||||
|
3. RESTRIÇÕES
|
||||||
|
-------------
|
||||||
|
É vedado ao Licenciado, sem autorização prévia e por escrito do Licenciante:
|
||||||
|
|
||||||
|
3.1. Copiar, modificar, adaptar, traduzir ou criar obras derivadas do
|
||||||
|
Software, total ou parcialmente;
|
||||||
|
3.2. Alugar, arrendar, emprestar, vender, redistribuir ou sublicenciar
|
||||||
|
o Software;
|
||||||
|
3.3. Realizar engenharia reversa, descompilar ou desmontar o Software,
|
||||||
|
exceto na medida estritamente permitida pela legislação aplicável;
|
||||||
|
3.4. Remover ou alterar quaisquer avisos de propriedade, marcas
|
||||||
|
registradas ou notações de direitos autorais;
|
||||||
|
3.5. Utilizar o Software para fins ilícitos, ofensivos ou que violem
|
||||||
|
direitos de terceiros ou a legislação em vigor.
|
||||||
|
|
||||||
|
4. PROPRIEDADE INTELECTUAL
|
||||||
|
--------------------------
|
||||||
|
Todo o Software, incluindo, mas não se limitando a, código-fonte, algoritmos,
|
||||||
|
estruturas de dados, design, textos, gráficos, marcas e logotipos, é de
|
||||||
|
propriedade exclusiva do Licenciante, protegido pelas leis brasileiras de
|
||||||
|
propriedade intelectual (Lei nº 9.610/1998) e por tratados internacionais
|
||||||
|
aplicáveis.
|
||||||
|
|
||||||
|
5. DADOS PESSOAIS E LGPD
|
||||||
|
------------------------
|
||||||
|
O Licenciado declara estar ciente de que o Software trata dados pessoais em
|
||||||
|
conformidade com a Lei nº 13.709/2018 (LGPD). O Licenciado é responsável
|
||||||
|
por quaisquer dados que venha a inserir na plataforma e deve respeitar os
|
||||||
|
direitos dos titulares de dados.
|
||||||
|
|
||||||
|
6. ATUALIZAÇÕES E SUPORTE
|
||||||
|
-------------------------
|
||||||
|
O Licenciante poderá, a seu exclusivo critério, fornecer atualizações,
|
||||||
|
correções e suporte. Não há obrigação de manutenção contínua, salvo acordo
|
||||||
|
específico firmado entre as partes.
|
||||||
|
|
||||||
|
7. LIMITAÇÃO DE RESPONSABILIDADE
|
||||||
|
--------------------------------
|
||||||
|
O Software é fornecido "no estado em que se encontra" (AS IS), sem garantias
|
||||||
|
de qualquer natureza, expressas ou implícitas, incluindo, mas não se limitando
|
||||||
|
a, garantias de comercialização, adequação a um propósito específico ou
|
||||||
|
inviolabilidade. O Licenciante não se responsabiliza por danos diretos,
|
||||||
|
indiretos, incidentais, especiais ou consequenciais decorrentes do uso ou da
|
||||||
|
impossibilidade de uso do Software.
|
||||||
|
|
||||||
|
8. VIOLAÇÃO E ENCERRAMENTO
|
||||||
|
--------------------------
|
||||||
|
Qualquer violação dos termos desta Licença resultará na rescisão automática
|
||||||
|
da licença concedida, sem prejuízo da apuração de eventuais danos e da
|
||||||
|
responsabilização civil e criminal cabível.
|
||||||
|
|
||||||
|
9. LEGISLAÇÃO APLICÁVEL E FORO
|
||||||
|
------------------------------
|
||||||
|
Este documento é regido pelas leis da República Federativa do Brasil.
|
||||||
|
Fica eleito o foro da Comarca de Santa Rita do Sapucaí — MG para dirimir
|
||||||
|
quaisquer controvérsias decorrentes desta Licença, com renúncia a qualquer
|
||||||
|
outro, por mais privilegiado que seja.
|
||||||
|
|
||||||
|
10. CONTATO
|
||||||
|
-----------
|
||||||
|
Dúvidas sobre esta Licença: contato@syspanel.com.br
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# City10 — Cidade 3D Virtual com Agentes de IA
|
||||||
|
|
||||||
|
**City10** é uma plataforma full-stack de simulação urbana 3D com agentes de
|
||||||
|
inteligência artificial autônomos, construída sobre **Laravel 13** e **Three.js**.
|
||||||
|
Agentes (Prefeito, pedestres, lojistas) tomam decisões estratégicas em tempo real
|
||||||
|
através da API [OpenRouter.ai](https://openrouter.ai), restrita a uma lista de
|
||||||
|
modelos gratuitos.
|
||||||
|
|
||||||
|
> Plataforma operada pela **Syspanel** — Santa Rita do Sapucaí/MG, Brasil.
|
||||||
|
> Contato: [contato@syspanel.com.br](mailto:contato@syspanel.com.br) ·
|
||||||
|
> WhatsApp: (35) 99226-1684 · [www.syspanel.com.br](https://syspanel.com.br)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visão Geral da Arquitetura
|
||||||
|
|
||||||
|
| Camada | Tecnologia | Responsabilidade |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend / Painel Admin | Laravel 13 (PHP ≥ 8.3) | Autenticação, API REST, regras de negócio, consentimento LGPD |
|
||||||
|
| Banco de Dados | MariaDB (MySQL) | Persistência de usuários, lojas, agentes e consentimentos |
|
||||||
|
| Cache / Fila | Redis / Database | Sessões, cache e jobs |
|
||||||
|
| Frontend 3D | Three.js (Fase 2+) | Renderização da cidade no navegador |
|
||||||
|
| Tempo Real | Laravel Reverb (planejado) | Sincronização de estado entre clientes |
|
||||||
|
| Cérebro dos Agentes | OpenRouter.ai | Raciocínio dos NPCs com modelos gratuitos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 1 (Atual) — Entregue
|
||||||
|
|
||||||
|
- Estrutura Laravel 13 com autenticação diferenciada (**Admin** / **Usuário Comum**).
|
||||||
|
- **Migrations**: usuários com `role`, lojas com coordenadas 3D (`pos_x`, `pos_y`, `pos_z`), agentes de IA e trilha de consentimento legal (LGPD).
|
||||||
|
- **API REST** (Sanctum) para usuários, lojas, agentes, cidade e documentos legais.
|
||||||
|
- **CAPTCHA matemático** próprio (soma), sem dependências externas, nos formulários públicos.
|
||||||
|
- **Multi-idioma**: Português (Brasil), Inglês e Espanhol — selecionável pelo usuário.
|
||||||
|
- **Termos de Uso, Política de Privacidade e Política de Cookies** (LGPD), versionados e com aceite auditável (data, hora, versão, IP).
|
||||||
|
- **Banner de cookies** com link para os Termos de Uso e Privacidade.
|
||||||
|
- **Página inicial** responsiva (desktop e smartphone).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- PHP **≥ 8.3** (com `pdo_mysql`, `mbstring`, `curl`, `zip`, `gd`, `bcmath`)
|
||||||
|
- Composer 2
|
||||||
|
- MariaDB ≥ 10.6 (ou MySQL ≥ 8.0)
|
||||||
|
- Node.js ≥ 18 (apenas para assets Vite/Tailwind, opcional na Fase 1)
|
||||||
|
- Docker (opcional, para o ambiente `dockercity10`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalação
|
||||||
|
|
||||||
|
### 1. Dependências PHP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configuração do ambiente
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Edite o `.env` e configure o banco de dados:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_CONNECTION=mariadb
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=laravel
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=<sua_senha>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Chave do OpenRouter.ai
|
||||||
|
|
||||||
|
A chave **não** é versionada. Insira-a apenas no `.env` local:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENROUTER_API_KEY=<sua_chave>
|
||||||
|
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Banco de dados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate --seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Servidor de desenvolvimento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan serve --port=16980
|
||||||
|
```
|
||||||
|
|
||||||
|
A aplicação ficará disponível em `http://localhost:16980`.
|
||||||
|
|
||||||
|
### Ambiente Docker (dockercity10)
|
||||||
|
|
||||||
|
O container `dockercity10` já expõe a aplicação na porta do host **16980**
|
||||||
|
(container `:80`, Apache + PHP-FPM) e o MariaDB na porta **16981**. Após clonar
|
||||||
|
o projeto em `/app` (bind-mount), rode as migrations dentro do container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it dockercity10 bash -c "cd /app && php artisan migrate --seed"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contas de Teste (ambiente de desenvolvimento)
|
||||||
|
|
||||||
|
| Papel | E-mail | Senha |
|
||||||
|
|---|---|---|
|
||||||
|
| Administrador | `marcocosta@gmx.com` | `Mega886ax` |
|
||||||
|
| Usuário Comum | `user@sistema.net` | `User123` |
|
||||||
|
|
||||||
|
> ⚠️ Estas credenciais são exclusivas para desenvolvimento. Altere-as ou
|
||||||
|
> remova-as (`php artisan db:seed --class=UserSeeder` após ajustar) antes de
|
||||||
|
> qualquer implantação em produção.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de Endpoints da API
|
||||||
|
|
||||||
|
Base URL: `http://localhost:16980/api`
|
||||||
|
|
||||||
|
### Públicos
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/auth/captcha` | Gera desafio matemático (necessário para login/registro) |
|
||||||
|
| POST | `/auth/login` | Autentica e emite token Sanctum |
|
||||||
|
| POST | `/auth/register` | Cria conta de usuário comum |
|
||||||
|
| GET | `/legal/documents` | Lista a versão atual dos documentos legais |
|
||||||
|
|
||||||
|
### Autenticados (Bearer token)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/auth/me` | Perfil + status de consentimento |
|
||||||
|
| PATCH | `/auth/profile` | Atualiza nome, idioma ou senha |
|
||||||
|
| POST | `/auth/logout` | Revoga o token atual |
|
||||||
|
| GET | `/city/world` | Snapshot da cidade (lojas + agentes com coordenadas 3D) |
|
||||||
|
| GET/POST | `/stores` | Lista/cria lojas |
|
||||||
|
| GET/PUT/DELETE | `/stores/{id}` | Gerencia uma loja (dono ou admin) |
|
||||||
|
| GET | `/legal/status` | Status de aceite dos documentos |
|
||||||
|
| POST | `/legal/accept` | Registra o aceite (auditoria LGPD) |
|
||||||
|
| GET | `/legal/history` | Histórico de consentimentos do usuário |
|
||||||
|
|
||||||
|
### Somente Administrador (`/admin`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET/POST | `/admin/users` | Lista/cria usuários |
|
||||||
|
| GET/PUT/DELETE | `/admin/users/{id}` | Gerencia usuários |
|
||||||
|
| GET/POST | `/admin/agents` | Lista/cria agentes de IA |
|
||||||
|
| GET/PUT/DELETE | `/admin/agents/{id}` | Gerencia agentes |
|
||||||
|
| GET | `/admin/agents-meta/models` | Lista os modelos gratuitos permitidos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modelos de IA Permitidos
|
||||||
|
|
||||||
|
Somente os modelos **gratuitos** abaixo podem ser atribuídos a agentes
|
||||||
|
(validado no backend em `config/ai.php`):
|
||||||
|
|
||||||
|
```
|
||||||
|
poolside/laguna-m.1:free cohere/north-mini-code:free
|
||||||
|
deepseek/deepseek-v4-flash-0731:free qwen/qwen3.8-27b:free
|
||||||
|
nvidia/nemotron-3-ultra-550b-a55b:free nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
google/gemma-4-31b-it:free google/gemma-4-26b-a4b-it:free
|
||||||
|
openai/gpt-oss-20b:free nvidia/nemotron-nano-12b-v2-vl:free
|
||||||
|
nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
|
||||||
|
nvidia/nemotron-3-nano-30b-a3b:free nvidia/nemotron-nano-9b-v2:free
|
||||||
|
poolside/laguna-xs-2.1:free inclusionai/ling-3.0-flash-vl:free
|
||||||
|
qwen/qwen3.7-flash:free
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segurança e Conformidade (LGPD)
|
||||||
|
|
||||||
|
- **Senhas** armazenadas com bcrypt (cast `hashed`).
|
||||||
|
- **Tokens de API** (Sanctum) revogáveis por usuário.
|
||||||
|
- **RBAC**: papéis `admin` / `user` com middleware dedicado.
|
||||||
|
- **CAPTCHA matemático** nos formulários públicos (login/registro).
|
||||||
|
- **Consentimento auditável**: data, hora, versão do documento, IP e user-agent
|
||||||
|
gravados em `user_consents` (imutáveis).
|
||||||
|
- **Banner de cookies** com opção de aceitar/recusar cookies não essenciais.
|
||||||
|
- **Direitos do titular** (art. 18 LGPD): histórico de consentimentos disponível
|
||||||
|
ao usuário; contato para exercer direitos: `contato@syspanel.com.br`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licença
|
||||||
|
|
||||||
|
Software **Proprietário** — © 2026 Syspanel. Todos os direitos reservados.
|
||||||
|
Veja o arquivo [`LICENSE`](LICENSE) para os termos completos de uso.
|
||||||
|
|
||||||
|
Bibliotecas de terceiros utilizadas são compatíveis com uso comercial
|
||||||
|
(por exemplo: Laravel — MIT; Three.js — MIT). O crédito aos autores originais
|
||||||
|
está registrado em [`RULES.md`](RULES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contato
|
||||||
|
|
||||||
|
- **E-mail**: [contato@syspanel.com.br](mailto:contato@syspanel.com.br)
|
||||||
|
- **WhatsApp**: (35) 99226-1684
|
||||||
|
- **Site**: [www.syspanel.com.br](https://syspanel.com.br)
|
||||||
|
- **Localização**: Santa Rita do Sapucaí — Minas Gerais — Brasil
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2026 — Todos os direitos reservados — Powered by
|
||||||
|
[Syspanel](https://syspanel.com.br) — www.syspanel.com.br
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# RULES.md — Regras para IA e Desenvolvedores do City10
|
||||||
|
|
||||||
|
Este documento reúne as regras obrigatórias que **modelos de IA** (agentes,
|
||||||
|
copilotos, assistentes de código) e **desenvolvedores humanos** devem seguir
|
||||||
|
ao trabalhar neste repositório.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Diretrizes Gerais
|
||||||
|
|
||||||
|
1.1. **Priorize a segurança.** Nunca introduza código que exponha, registre ou
|
||||||
|
transmita segredos (chaves de API, senhas, tokens). Chave do OpenRouter
|
||||||
|
vive apenas em `.env` (não versionado).
|
||||||
|
|
||||||
|
1.2. **Não remova a proteção LGPD.** Consentimento, banner de cookies,
|
||||||
|
trilha de auditoria e direitos do titular são obrigatórios e não podem
|
||||||
|
ser desativados ou contornados.
|
||||||
|
|
||||||
|
1.3. **Comentários PHPDoc em inglês.** Todo arquivo PHP de origem deve
|
||||||
|
possuir comentários técnicos em inglês usando o padrão PHPDoc.
|
||||||
|
|
||||||
|
1.4. **Multi-idioma.** Toda string visível ao usuário deve existir nos
|
||||||
|
arquivos `lang/pt_BR.json`, `lang/en.json` e `lang/es.json`.
|
||||||
|
|
||||||
|
1.5. **CSRF em formulários.** Todo formulário HTML deve incluir `@csrf`.
|
||||||
|
Nunca desative o middleware `VerifyCsrfToken`.
|
||||||
|
|
||||||
|
1.6. **CAPTCHA nos formulários públicos.** Login e registro devem manter o
|
||||||
|
CAPTCHA matemático (soma) nativo, sem bibliotecas externas.
|
||||||
|
|
||||||
|
1.7. **Senhas com ícone "eye".** Campos de senha em formulários devem exibir
|
||||||
|
o botão de alternar visibilidade (olho).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Regras para Modelos de IA (Agentes do City10)
|
||||||
|
|
||||||
|
2.1. **Somente modelos gratuitos allow-listed.** Agentes só podem usar
|
||||||
|
modelos definidos em `config/ai.php` (`allowed_models`). A validação
|
||||||
|
acontece no backend (`AiAgentRequest`) — não contorne.
|
||||||
|
|
||||||
|
2.2. **Nunca envie dados pessoais ao modelo.** Ao chamar o OpenRouter, envie
|
||||||
|
apenas contexto simulado da cidade (posições, metas, estado de lojas).
|
||||||
|
Nunca inclua nome, e-mail, senha ou outros dados cadastrais de usuários.
|
||||||
|
|
||||||
|
2.3. **Respeite o intervalo de raciocínio.** Não faça chamadas à API com
|
||||||
|
frequência maior que `config('ai.think_interval')` por agente.
|
||||||
|
|
||||||
|
2.4. **Trate falhas com backoff.** Erros da API devem incrementar
|
||||||
|
`error_count` e usar espera exponencial; não repita chamadas em loop.
|
||||||
|
|
||||||
|
2.5. **Persista metas e decisões.** O estado do agente (`current_goal`,
|
||||||
|
`last_decision`) deve ser gravado no banco para auditoria e retomada.
|
||||||
|
|
||||||
|
2.6. **Não execute ações destrutivas por conta própria.** Decisões que
|
||||||
|
alterem dados de usuários ou excluam recursos exigem aprovação humana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Regras para Desenvolvedores
|
||||||
|
|
||||||
|
3.1. **Estrutura Laravel 13 (estilo atual)**
|
||||||
|
- Middleware customizado em `app/Http/Middleware/`, registrado em
|
||||||
|
`bootstrap/app.php` via alias.
|
||||||
|
- Controllers em `app/Http/Controllers/{Api,Web}/`.
|
||||||
|
- Validação em `app/Http/Requests/`.
|
||||||
|
- Rotas em `routes/api.php` e `routes/web.php`.
|
||||||
|
|
||||||
|
3.2. **Migrations**
|
||||||
|
- Nunca edite uma migration já executada em produção; crie uma nova.
|
||||||
|
- Use tipos adequados (`foreignId`, `decimal` para coordenadas 3D).
|
||||||
|
|
||||||
|
3.3. **Eloquent**
|
||||||
|
- Declare relações e `casts()` nos models.
|
||||||
|
- Use `$fillable` (ou attributes `#[Fillable]`) para evitar mass-assignment.
|
||||||
|
|
||||||
|
3.4. **Autorização**
|
||||||
|
- `admin` e `user` são os papéis suportados (`User::ROLE_*`).
|
||||||
|
- Proteja rotas administrativas com o middleware `admin`.
|
||||||
|
|
||||||
|
3.5. **Testes**
|
||||||
|
- Rode `php artisan test` antes de considerar uma tarefa concluída.
|
||||||
|
- Rode `./vendor/bin/pint --test` para validar o estilo de código.
|
||||||
|
|
||||||
|
3.6. **Bibliotecas e licenças**
|
||||||
|
- Antes de adicionar qualquer dependência, verifique a licença.
|
||||||
|
- Apenas licenças que permitam uso comercial (MIT, Apache-2.0, BSD,
|
||||||
|
ISC, etc.) são aceitáveis.
|
||||||
|
- Registre a licença e o autor aqui (ver seção 4).
|
||||||
|
|
||||||
|
3.7. **Imagens e assets**
|
||||||
|
- Use apenas imagens, fontes e ícones de licença livre ou própria.
|
||||||
|
|
||||||
|
3.8. **Variáveis de ambiente**
|
||||||
|
- Adicione novas chaves ao `.env.example` (sem valores secretos).
|
||||||
|
- Documente o propósito no `.env` com comentário.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Registro de Bibliotecas e Licenças
|
||||||
|
|
||||||
|
| Biblioteca | Licença | Autor / Origem | Uso |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Laravel Framework | MIT | Laravel LLC / Taylor Otwell | Backend |
|
||||||
|
| Laravel Sanctum | MIT | Laravel LLC | Tokens de API |
|
||||||
|
| Three.js | MIT | Ricardo Cabello (Mr.doob) e contribuidores | Renderização 3D (Fase 2) |
|
||||||
|
| Tailwind CSS | MIT | Tailwind Labs | Estilos |
|
||||||
|
| Instrument Sans | OFL 1.1 | Instrument (Google Fonts) | Tipografia |
|
||||||
|
| PHP | PHP License | The PHP Group | Runtime |
|
||||||
|
| MariaDB | GPL / comercial | MariaDB Foundation | Banco de dados |
|
||||||
|
|
||||||
|
> Observação: licenças MIT, Apache-2.0, BSD, ISC e OFL permitem uso comercial
|
||||||
|
> livremente, atendendo à exigência da Syspanel. Registre aqui toda nova
|
||||||
|
> dependência antes de mergear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Padrões de Código
|
||||||
|
|
||||||
|
5.1. Siga o estilo do Laravel (Pint). Formate com `./vendor/bin/pint`.
|
||||||
|
|
||||||
|
5.2. Prefira código explícito a "mágica" obscura; legibilidade primeiro.
|
||||||
|
|
||||||
|
5.3. Valide sempre a entrada do usuário (Form Requests).
|
||||||
|
|
||||||
|
5.4. Retorne respostas de erro consistentes (JSON com `message` e `errors`).
|
||||||
|
|
||||||
|
5.5. Use as traduções (`__()` / `@lang`) em vez de strings literais
|
||||||
|
no meio das views e controllers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Commits e Versionamento
|
||||||
|
|
||||||
|
6.1. Mensagens de commit claras e no imperativo (ex.: `Add store CRUD API`).
|
||||||
|
|
||||||
|
6.2. Nunca commite: `.env`, chaves, `storage/logs/*`, `vendor/`, `node_modules/`.
|
||||||
|
|
||||||
|
6.3. Uma mudança por commit quando possível; mantenha o histórico legível.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2026 — Todos os direitos reservados — Powered by
|
||||||
|
[Syspanel](https://syspanel.com.br) — www.syspanel.com.br
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\AiAgentRequest;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI agent management endpoints (admin only).
|
||||||
|
*
|
||||||
|
* Agents power the NPCs and managers of the virtual city through the
|
||||||
|
* OpenRouter.ai API, restricted to the allow-listed free models.
|
||||||
|
*/
|
||||||
|
class AiAgentController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Return the list of models allowed for agent configuration.
|
||||||
|
*/
|
||||||
|
public function models(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'models' => config('ai.allowed_models', []),
|
||||||
|
'default' => config('ai.default_model'),
|
||||||
|
'api_key_configured' => filled(config('ai.openrouter.api_key')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated listing of registered AI agents.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = AiAgent::query()->orderBy('name');
|
||||||
|
|
||||||
|
if ($type = $request->query('type')) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a compact snapshot of active agents for the 3D renderer.
|
||||||
|
*/
|
||||||
|
public function world(): JsonResponse
|
||||||
|
{
|
||||||
|
$agents = AiAgent::query()
|
||||||
|
->where('status', '!=', 'paused')
|
||||||
|
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'current_goal'])
|
||||||
|
->map(fn (AiAgent $agent): array => [
|
||||||
|
'id' => $agent->id,
|
||||||
|
'name' => $agent->name,
|
||||||
|
'type' => $agent->type,
|
||||||
|
'status' => $agent->status,
|
||||||
|
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||||
|
'rotation_y' => (float) $agent->rotation_y,
|
||||||
|
'goal' => $agent->current_goal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['agents' => $agents]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a new AI agent.
|
||||||
|
*/
|
||||||
|
public function store(AiAgentRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$data['model'] = $data['model'] ?? config('ai.default_model');
|
||||||
|
$data['status'] = $data['status'] ?? 'active';
|
||||||
|
|
||||||
|
$agent = AiAgent::create($data);
|
||||||
|
|
||||||
|
return response()->json($agent, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single AI agent.
|
||||||
|
*/
|
||||||
|
public function show(AiAgent $aiAgent): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($aiAgent->load('stores:id,name,agent_id,is_open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an AI agent.
|
||||||
|
*/
|
||||||
|
public function update(AiAgentRequest $request, AiAgent $aiAgent): JsonResponse
|
||||||
|
{
|
||||||
|
$aiAgent->update($request->validated());
|
||||||
|
|
||||||
|
return response()->json($aiAgent->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an AI agent.
|
||||||
|
*/
|
||||||
|
public function destroy(AiAgent $aiAgent): JsonResponse
|
||||||
|
{
|
||||||
|
$aiAgent->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => __('agents.deleted')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Http\Requests\RegisterRequest;
|
||||||
|
use App\Models\LegalDocument;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication endpoints for the City10 REST API.
|
||||||
|
*
|
||||||
|
* Issues personal access tokens (Laravel Sanctum) consumed by the 3D
|
||||||
|
* frontend and by external integrations.
|
||||||
|
*/
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Issue a fresh math CAPTCHA challenge for the login/registration forms.
|
||||||
|
*
|
||||||
|
* The expected result is stored server-side in the session, so clients
|
||||||
|
* only receive the question to solve.
|
||||||
|
*/
|
||||||
|
public function captcha(): JsonResponse
|
||||||
|
{
|
||||||
|
$a = random_int(1, 9);
|
||||||
|
$b = random_int(1, 9);
|
||||||
|
|
||||||
|
session()->put('captcha_result', $a + $b);
|
||||||
|
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new regular user account.
|
||||||
|
*
|
||||||
|
* Applies a simple math CAPTCHA and immediately records acceptance of
|
||||||
|
* the current legal documents when the client confirms them.
|
||||||
|
*/
|
||||||
|
public function register(RegisterRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => $data['password'],
|
||||||
|
'role' => User::ROLE_USER,
|
||||||
|
'locale' => $data['locale'] ?? 'pt_BR',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $user->createToken('city10-api')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'user' => $user,
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a user and return an API token.
|
||||||
|
*/
|
||||||
|
public function login(LoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = User::where('email', $request->validated('email'))->first();
|
||||||
|
|
||||||
|
if ($user === null || ! Hash::check($request->validated('password'), $user->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('auth.failed')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('auth.disabled')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(['last_login_at' => now()])->save();
|
||||||
|
|
||||||
|
$token = $user->createToken('city10-api')->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'user' => $user,
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the authenticated user's own profile (name, locale, password).
|
||||||
|
*
|
||||||
|
* Role and active status are intentionally excluded: those are managed
|
||||||
|
* exclusively through the admin endpoints.
|
||||||
|
*/
|
||||||
|
public function updateProfile(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:120'],
|
||||||
|
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
|
||||||
|
'password' => ['sometimes', 'string', 'min:8', 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! empty($data['password'])) {
|
||||||
|
$user->password = $data['password'];
|
||||||
|
unset($data['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->fill($data)->save();
|
||||||
|
|
||||||
|
return response()->json($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke the token used for the current request.
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->currentAccessToken()?->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => __('auth.logged_out')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the authenticated user plus consent status.
|
||||||
|
*/
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$currentDocuments = LegalDocument::query()
|
||||||
|
->where('is_current', true)
|
||||||
|
->get(['id', 'type', 'version']);
|
||||||
|
|
||||||
|
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'user' => $user,
|
||||||
|
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
|
||||||
|
'pending_documents' => $currentDocuments
|
||||||
|
->reject(fn ($doc) => $acceptedIds->contains($doc->id))
|
||||||
|
->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Store;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated city snapshot consumed by the Three.js frontend.
|
||||||
|
*
|
||||||
|
* Provides a single lightweight payload describing the static and dynamic
|
||||||
|
* elements of the virtual city (stores and AI agents with coordinates).
|
||||||
|
*/
|
||||||
|
class CityController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Return the current world state used to bootstrap the 3D scene.
|
||||||
|
*/
|
||||||
|
public function world(): JsonResponse
|
||||||
|
{
|
||||||
|
$stores = Store::query()
|
||||||
|
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||||
|
->map(fn (Store $store): array => [
|
||||||
|
'id' => $store->id,
|
||||||
|
'name' => $store->name,
|
||||||
|
'type' => $store->type,
|
||||||
|
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||||
|
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||||
|
'is_open' => $store->is_open,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agents = AiAgent::query()
|
||||||
|
->where('status', '!=', 'paused')
|
||||||
|
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y'])
|
||||||
|
->map(fn (AiAgent $agent): array => [
|
||||||
|
'id' => $agent->id,
|
||||||
|
'name' => $agent->name,
|
||||||
|
'type' => $agent->type,
|
||||||
|
'status' => $agent->status,
|
||||||
|
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||||
|
'rotation_y' => (float) $agent->rotation_y,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
'stores' => $stores,
|
||||||
|
'agents' => $agents,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\ConsentRequest;
|
||||||
|
use App\Models\LegalDocument;
|
||||||
|
use App\Models\UserConsent;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal document and consent endpoints.
|
||||||
|
*
|
||||||
|
* Exposes the current Terms of Use, Privacy Policy and Cookie Policy and
|
||||||
|
* records auditable consent events required by the Brazilian LGPD.
|
||||||
|
*/
|
||||||
|
class LegalController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Return the current version of every legal document.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locale = $request->query('locale', 'pt_BR');
|
||||||
|
|
||||||
|
$documents = LegalDocument::query()
|
||||||
|
->where('is_current', true)
|
||||||
|
->orderBy('type')
|
||||||
|
->get()
|
||||||
|
->map(fn (LegalDocument $doc): array => [
|
||||||
|
'id' => $doc->id,
|
||||||
|
'type' => $doc->type,
|
||||||
|
'version' => $doc->version,
|
||||||
|
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
|
||||||
|
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
|
||||||
|
'published_at' => $doc->published_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['documents' => $documents]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the consent status of the authenticated user.
|
||||||
|
*/
|
||||||
|
public function status(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$current = LegalDocument::query()->where('is_current', true)->get();
|
||||||
|
$accepted = $user->consents()->get(['legal_document_id', 'accepted_at']);
|
||||||
|
|
||||||
|
$items = $current->map(fn (LegalDocument $doc): array => [
|
||||||
|
'id' => $doc->id,
|
||||||
|
'type' => $doc->type,
|
||||||
|
'version' => $doc->version,
|
||||||
|
'accepted' => $accepted->contains('legal_document_id', $doc->id),
|
||||||
|
'accepted_at' => $accepted->firstWhere('legal_document_id', $doc->id)?->accepted_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'all_accepted' => $items->every(fn (array $item): bool => $item['accepted']),
|
||||||
|
'documents' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the acceptance of all current legal documents.
|
||||||
|
*
|
||||||
|
* The event is stored with timestamp, client IP, user agent and the
|
||||||
|
* exact document versions accepted, forming an immutable audit trail.
|
||||||
|
*/
|
||||||
|
public function accept(ConsentRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$documents = LegalDocument::query()->where('is_current', true)->get();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $documents, $request): void {
|
||||||
|
foreach ($documents as $document) {
|
||||||
|
UserConsent::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'legal_document_id' => $document->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'accepted_at' => now(),
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => (string) $request->userAgent(),
|
||||||
|
'source' => 'web_form',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('legal.accepted'),
|
||||||
|
'accepted_at' => now()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the full consent audit history of the authenticated user.
|
||||||
|
*/
|
||||||
|
public function history(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$history = $request->user()
|
||||||
|
->consents()
|
||||||
|
->with('document:id,type,version')
|
||||||
|
->orderByDesc('accepted_at')
|
||||||
|
->get()
|
||||||
|
->map(fn (UserConsent $consent): array => [
|
||||||
|
'document_type' => $consent->document?->type,
|
||||||
|
'document_version' => $consent->document?->version,
|
||||||
|
'accepted_at' => $consent->accepted_at?->toIso8601String(),
|
||||||
|
'ip_address' => $consent->ip_address,
|
||||||
|
'source' => $consent->source,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['history' => $history]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreRequest as StoreFormRequest;
|
||||||
|
use App\Models\Store;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store management endpoints.
|
||||||
|
*
|
||||||
|
* Regular users may list stores and manage the ones they own; admins may
|
||||||
|
* manage the entire registry. Coordinates follow the 3D city grid.
|
||||||
|
*/
|
||||||
|
class StoreController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Paginated listing of stores with geographic filtering.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Store::query()->with(['owner:id,name', 'agent:id,name,status'])->orderBy('name');
|
||||||
|
|
||||||
|
if ($search = $request->query('search')) {
|
||||||
|
$query->where('name', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type = $request->query('type')) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('only_open')) {
|
||||||
|
$query->where('is_open', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a compact snapshot of all stores for the 3D renderer.
|
||||||
|
*/
|
||||||
|
public function world(): JsonResponse
|
||||||
|
{
|
||||||
|
$stores = Store::query()
|
||||||
|
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||||
|
->map(fn (Store $store): array => [
|
||||||
|
'id' => $store->id,
|
||||||
|
'name' => $store->name,
|
||||||
|
'type' => $store->type,
|
||||||
|
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||||
|
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||||
|
'is_open' => $store->is_open,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['stores' => $stores]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a new store.
|
||||||
|
*/
|
||||||
|
public function store(StoreFormRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Regular users always own the stores they create.
|
||||||
|
if (! $request->user()->isAdmin()) {
|
||||||
|
$data['owner_id'] = $request->user()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store = Store::create($data);
|
||||||
|
|
||||||
|
return response()->json($store->load(['owner:id,name', 'agent:id,name']), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single store.
|
||||||
|
*/
|
||||||
|
public function show(Store $store): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($store->load(['owner:id,name,email', 'agent:id,name,type,status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a store (owner or admin only).
|
||||||
|
*/
|
||||||
|
public function update(StoreFormRequest $request, Store $store): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
|
||||||
|
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->update($request->validated());
|
||||||
|
|
||||||
|
return response()->json($store->fresh()->load(['owner:id,name', 'agent:id,name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a store (owner or admin only).
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, Store $store): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
|
||||||
|
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => __('stores.deleted')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User management endpoints (admin only).
|
||||||
|
*
|
||||||
|
* Lists, creates, updates and deactivates accounts of the platform while
|
||||||
|
* exposing never the password hash or other sensitive material.
|
||||||
|
*/
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Paginated listing of user accounts with optional search.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = User::query()->orderBy('name');
|
||||||
|
|
||||||
|
if ($search = $request->query('search')) {
|
||||||
|
$query->where(function ($q) use ($search): void {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role = $request->query('role')) {
|
||||||
|
$query->where('role', $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a new user account (admin provisioning).
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||||
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
'role' => ['required', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
|
||||||
|
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
|
||||||
|
'is_active' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create($data);
|
||||||
|
|
||||||
|
return response()->json($user, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single user account.
|
||||||
|
*/
|
||||||
|
public function show(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($user->load('stores:id,name,owner_id,is_open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing user account.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:120'],
|
||||||
|
'email' => ['sometimes', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||||
|
'password' => ['sometimes', 'string', 'min:8'],
|
||||||
|
'role' => ['sometimes', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
|
||||||
|
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->fill($data);
|
||||||
|
|
||||||
|
if (! empty($data['password'])) {
|
||||||
|
$user->password = $data['password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-disable an account (keeps audit data intact).
|
||||||
|
*
|
||||||
|
* Prevents administrators from locking themselves out of the platform.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
if ($user->id === $request->user()?->id) {
|
||||||
|
return response()->json(['message' => __('users.cannot_deactivate_self')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update(['is_active' => false]);
|
||||||
|
|
||||||
|
return response()->json(['message' => __('users.deactivated')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreRequest;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Store;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrative store management (admin only).
|
||||||
|
*
|
||||||
|
* Provides the server-rendered CRUD screens where administrators create
|
||||||
|
* and edit stores, defining their exact 3D placement in the virtual city
|
||||||
|
* through a Three.js position picker.
|
||||||
|
*/
|
||||||
|
class StoreAdminController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Paginated listing of every store with search and type filtering.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$query = Store::query()->with(['owner:id,name', 'agent:id,name'])->orderBy('name');
|
||||||
|
|
||||||
|
if ($search = $request->query('search')) {
|
||||||
|
$query->where('name', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type = $request->query('type')) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = Store::query()->select('type')->distinct()->orderBy('type')->pluck('type');
|
||||||
|
|
||||||
|
return view('admin.stores.index', [
|
||||||
|
'stores' => $query->paginate(12)->withQueryString(),
|
||||||
|
'types' => $types,
|
||||||
|
'currentSearch' => $search,
|
||||||
|
'currentType' => $type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the store creation form with the 3D position picker.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.stores.form', [
|
||||||
|
'store' => new Store(['is_open' => true, 'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0, 'width' => 8, 'height' => 6, 'depth' => 8]),
|
||||||
|
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
|
||||||
|
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
|
||||||
|
'isEdit' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a newly created store.
|
||||||
|
*/
|
||||||
|
public function store(StoreRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$store = Store::create($request->validated());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.stores.edit', $store)
|
||||||
|
->with('status', __('admin.stores.created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the store edit form pre-filled with the current 3D placement.
|
||||||
|
*/
|
||||||
|
public function edit(Store $store): View
|
||||||
|
{
|
||||||
|
return view('admin.stores.form', [
|
||||||
|
'store' => $store,
|
||||||
|
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
|
||||||
|
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
|
||||||
|
'isEdit' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing store.
|
||||||
|
*/
|
||||||
|
public function update(StoreRequest $request, Store $store): RedirectResponse
|
||||||
|
{
|
||||||
|
$store->update($request->validated());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.stores.index')
|
||||||
|
->with('status', __('admin.stores.updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a store from the city registry.
|
||||||
|
*/
|
||||||
|
public function destroy(Store $store): RedirectResponse
|
||||||
|
{
|
||||||
|
$store->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.stores.index')
|
||||||
|
->with('status', __('stores.deleted'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Http\Requests\RegisterRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web authentication controllers (session based).
|
||||||
|
*
|
||||||
|
* Complements the token-based API with a traditional server-rendered
|
||||||
|
* login and registration flow including a math CAPTCHA.
|
||||||
|
*/
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the login form.
|
||||||
|
*/
|
||||||
|
public function showLogin(): View
|
||||||
|
{
|
||||||
|
return view('auth.login', [
|
||||||
|
'captcha' => self::makeCaptcha(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the login form submission.
|
||||||
|
*/
|
||||||
|
public function login(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->only('email', 'password');
|
||||||
|
$remember = $request->boolean('remember');
|
||||||
|
|
||||||
|
if (! Auth::attempt($credentials, $remember)) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'email' => __('auth.failed'),
|
||||||
|
])->onlyInput('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->withErrors([
|
||||||
|
'email' => __('auth.disabled'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(['last_login_at' => now()])->save();
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
// Seed the session locale from the user's stored preference so the
|
||||||
|
// SetLocale middleware renders the UI in their chosen language.
|
||||||
|
if (in_array($user->locale, ['pt_BR', 'en', 'es'], true)) {
|
||||||
|
$request->session()->put('locale', $user->locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the registration form.
|
||||||
|
*/
|
||||||
|
public function showRegister(): View
|
||||||
|
{
|
||||||
|
return view('auth.register', [
|
||||||
|
'captcha' => self::makeCaptcha(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the registration form submission.
|
||||||
|
*/
|
||||||
|
public function register(RegisterRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => $data['password'],
|
||||||
|
'role' => User::ROLE_USER,
|
||||||
|
'locale' => $data['locale'] ?? 'pt_BR',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->route('terms.accept');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the user out of the web session.
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh math CAPTCHA challenge.
|
||||||
|
*
|
||||||
|
* @return array{a: int, b: int, question: string}
|
||||||
|
*/
|
||||||
|
private static function makeCaptcha(): array
|
||||||
|
{
|
||||||
|
$a = random_int(1, 9);
|
||||||
|
$b = random_int(1, 9);
|
||||||
|
|
||||||
|
session()->put('captcha_result', $a + $b);
|
||||||
|
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'a' => $a,
|
||||||
|
'b' => $b,
|
||||||
|
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Store;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\StreamedResponse;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NPC chat endpoints consumed by the 3D city viewer.
|
||||||
|
*
|
||||||
|
* The POST endpoint accepts a user message, enqueues the OpenRouter call
|
||||||
|
* asynchronously and immediately returns 202 so the UI can show a
|
||||||
|
* "thinking" state. The GET endpoint streams agent replies as SSE events.
|
||||||
|
* Only simulated city context (never personal data) is sent upstream,
|
||||||
|
* as required by RULES.md.
|
||||||
|
*/
|
||||||
|
class ChatController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a message to an agent and trigger an asynchronous reply.
|
||||||
|
*/
|
||||||
|
public function send(Request $request, AiAgent $agent): Response
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'message' => ['required', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Persist the conversation in the agent's last_decision for audit
|
||||||
|
// and stream the pending state to any connected SSE listener.
|
||||||
|
$history = $agent->last_decision ?? [];
|
||||||
|
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
|
||||||
|
$agent->update([
|
||||||
|
'last_decision' => array_slice($history, -20),
|
||||||
|
'status' => 'thinking',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
|
||||||
|
|
||||||
|
// Fire the OpenRouter call without blocking the HTTP response.
|
||||||
|
dispatch(function () use ($agent, $data): void {
|
||||||
|
self::think($agent, $data['message']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'accepted',
|
||||||
|
'agent_id' => $agent->id,
|
||||||
|
'message' => __('city.chat_accepted'),
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream agent status and replies as Server-Sent Events.
|
||||||
|
*/
|
||||||
|
public function stream(Request $request, AiAgent $agent): StreamedResponse
|
||||||
|
{
|
||||||
|
$request->session()->save();
|
||||||
|
|
||||||
|
return response()->stream(function () use ($agent): void {
|
||||||
|
echo "retry: 3000\n\n";
|
||||||
|
flush();
|
||||||
|
|
||||||
|
$last = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (connection_aborted()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll the cache for a message published by think().
|
||||||
|
$payload = cache()->pull("chat.{$agent->id}");
|
||||||
|
|
||||||
|
if ($payload !== null) {
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
echo "event: message\n";
|
||||||
|
echo "data: {$json}\n\n";
|
||||||
|
flush();
|
||||||
|
$last = $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat keeps proxies from closing the stream.
|
||||||
|
echo ": ping\n\n";
|
||||||
|
flush();
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
}, 200, [
|
||||||
|
'Content-Type' => 'text/event-stream',
|
||||||
|
'Cache-Control' => 'no-cache',
|
||||||
|
'X-Accel-Buffering' => 'no',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call OpenRouter with the agent persona and simulated city context.
|
||||||
|
*
|
||||||
|
* Never includes personal data — only city state (RULES.md 2.2).
|
||||||
|
*/
|
||||||
|
private static function think(AiAgent $agent, string $userMessage): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$apiKey = config('ai.openrouter.api_key');
|
||||||
|
|
||||||
|
if (! filled($apiKey)) {
|
||||||
|
self::fail($agent, 'OPENROUTER_API_KEY not configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulated city context only — no PII (RULES.md 2.2).
|
||||||
|
$cityContext = [
|
||||||
|
'stores' => Store::query()
|
||||||
|
->get(['name', 'type', 'is_open'])
|
||||||
|
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
|
||||||
|
->all(),
|
||||||
|
'agent_name' => $agent->name,
|
||||||
|
'agent_type' => $agent->type,
|
||||||
|
'current_goal' => $agent->current_goal,
|
||||||
|
];
|
||||||
|
|
||||||
|
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
|
||||||
|
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
|
||||||
|
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
|
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
||||||
|
'X-Title' => config('ai.openrouter.app_name'),
|
||||||
|
])->timeout((int) config('ai.openrouter.timeout', 30))
|
||||||
|
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
||||||
|
'model' => $agent->model,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'system', 'content' => $system],
|
||||||
|
['role' => 'user', 'content' => $userMessage],
|
||||||
|
],
|
||||||
|
'max_tokens' => 300,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
self::fail($agent, 'HTTP '.$response->status());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
|
||||||
|
|
||||||
|
$history = $agent->last_decision ?? [];
|
||||||
|
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
|
||||||
|
|
||||||
|
$agent->update([
|
||||||
|
'last_decision' => array_slice($history, -20),
|
||||||
|
'status' => 'active',
|
||||||
|
'error_count' => 0,
|
||||||
|
'last_think_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::publish($agent, [
|
||||||
|
'type' => 'reply',
|
||||||
|
'agent_id' => $agent->id,
|
||||||
|
'name' => $agent->name,
|
||||||
|
'reply' => $reply,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
|
||||||
|
self::fail($agent, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failure with exponential backoff (RULES.md 2.4).
|
||||||
|
*/
|
||||||
|
private static function fail(AiAgent $agent, string $reason): void
|
||||||
|
{
|
||||||
|
$errors = (int) $agent->error_count + 1;
|
||||||
|
$backoff = min(300, 2 ** min($errors, 8));
|
||||||
|
|
||||||
|
$agent->update([
|
||||||
|
'status' => $errors >= 5 ? 'error' : 'active',
|
||||||
|
'error_count' => $errors,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::publish($agent, [
|
||||||
|
'type' => 'error',
|
||||||
|
'agent_id' => $agent->id,
|
||||||
|
'reply' => __('city.chat_error'),
|
||||||
|
'status' => $agent->status,
|
||||||
|
'retry_after' => $backoff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a message for the SSE stream to pick up.
|
||||||
|
*/
|
||||||
|
private static function publish(AiAgent $agent, array $payload): void
|
||||||
|
{
|
||||||
|
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Store;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D city viewer.
|
||||||
|
*
|
||||||
|
* Serves the Three.js scene page plus a lightweight JSON payload of stores
|
||||||
|
* and agents. The JSON endpoint reuses the web session so the browser can
|
||||||
|
* fetch world data without a separate API token.
|
||||||
|
*/
|
||||||
|
class CityViewerController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render the city viewer page.
|
||||||
|
*/
|
||||||
|
public function __invoke(): View
|
||||||
|
{
|
||||||
|
return view('city.viewer');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the world snapshot consumed by the viewer scene.
|
||||||
|
*/
|
||||||
|
public function data(): JsonResponse
|
||||||
|
{
|
||||||
|
$stores = Store::query()
|
||||||
|
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||||
|
->map(fn (Store $store): array => [
|
||||||
|
'id' => $store->id,
|
||||||
|
'name' => $store->name,
|
||||||
|
'type' => $store->type,
|
||||||
|
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||||
|
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||||
|
'is_open' => $store->is_open,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agents = AiAgent::query()
|
||||||
|
->where('status', '!=', 'paused')
|
||||||
|
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
|
||||||
|
->map(fn (AiAgent $agent): array => [
|
||||||
|
'id' => $agent->id,
|
||||||
|
'name' => $agent->name,
|
||||||
|
'type' => $agent->type,
|
||||||
|
'status' => $agent->status,
|
||||||
|
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||||
|
'rotation_y' => (float) $agent->rotation_y,
|
||||||
|
'last_decision' => $agent->last_decision,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
'stores' => $stores,
|
||||||
|
'agents' => $agents,
|
||||||
|
'layout' => self::cityLayout($stores->all()),
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a deterministic street layout from store positions.
|
||||||
|
*
|
||||||
|
* Returns road segments and block rectangles so the frontend can build
|
||||||
|
* a consistent grid-based city without additional database tables.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $stores
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function cityLayout(array $stores): array
|
||||||
|
{
|
||||||
|
if ($stores === []) {
|
||||||
|
return ['roads' => [], 'blocks' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$xs = array_map(fn ($s) => $s['position'][0], $stores);
|
||||||
|
$zs = array_map(fn ($s) => $s['position'][2], $stores);
|
||||||
|
|
||||||
|
$minX = floor(min($xs) / 40) * 40 - 40;
|
||||||
|
$maxX = ceil(max($xs) / 40) * 40 + 40;
|
||||||
|
$minZ = floor(min($zs) / 40) * 40 - 40;
|
||||||
|
$maxZ = ceil(max($zs) / 40) * 40 + 40;
|
||||||
|
|
||||||
|
$roads = [];
|
||||||
|
|
||||||
|
// Vertical roads every 40 units.
|
||||||
|
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||||
|
$roads[] = [
|
||||||
|
'from' => [(float) $x, 0.0, (float) $minZ],
|
||||||
|
'to' => [(float) $x, 0.0, (float) $maxZ],
|
||||||
|
'width' => 8.0,
|
||||||
|
'axis' => 'z',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal roads every 40 units.
|
||||||
|
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||||
|
$roads[] = [
|
||||||
|
'from' => [(float) $minX, 0.0, (float) $z],
|
||||||
|
'to' => [(float) $maxX, 0.0, (float) $z],
|
||||||
|
'width' => 8.0,
|
||||||
|
'axis' => 'x',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks = cells between roads.
|
||||||
|
$blocks = [];
|
||||||
|
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||||
|
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||||
|
$blocks[] = [
|
||||||
|
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
|
||||||
|
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['roads' => $roads, 'blocks' => $blocks];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard controller for the authenticated web area.
|
||||||
|
*
|
||||||
|
* Phase 1 renders an overview panel; the full admin console and 3D city
|
||||||
|
* viewer are delivered in subsequent phases.
|
||||||
|
*/
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user dashboard overview.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return view('dashboard.index', [
|
||||||
|
'user' => $user,
|
||||||
|
'isAdmin' => $user->isAdmin(),
|
||||||
|
'storeCount' => $user->stores()->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language switcher controller.
|
||||||
|
*
|
||||||
|
* Persists the selected locale (pt_BR, en, es) in the session and in the
|
||||||
|
* user's profile when authenticated, so the whole UI is rendered in the
|
||||||
|
* language chosen from the dashboard or the public pages.
|
||||||
|
*/
|
||||||
|
class LanguageController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Switch the application locale for the current visitor.
|
||||||
|
*/
|
||||||
|
public function switch(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$locale = $request->input('locale');
|
||||||
|
|
||||||
|
if (! in_array($locale, ['pt_BR', 'en', 'es'], true)) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
App::setLocale($locale);
|
||||||
|
$request->session()->put('locale', $locale);
|
||||||
|
|
||||||
|
if ($user = $request->user()) {
|
||||||
|
$user->update(['locale' => $locale]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the cookie preference (1 year) used on subsequent visits.
|
||||||
|
$response = back();
|
||||||
|
$response->withCookie(cookie('city10-locale', $locale, 60 * 24 * 365, '/', null, false, true));
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\ConsentRequest;
|
||||||
|
use App\Models\LegalDocument;
|
||||||
|
use App\Models\UserConsent;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web controllers for legal documents and consent management.
|
||||||
|
*
|
||||||
|
* Renders the Terms of Use, Privacy Policy and Cookie Policy and records
|
||||||
|
* the auditable acceptance required by the Brazilian LGPD before the
|
||||||
|
* user can access the main platform features.
|
||||||
|
*/
|
||||||
|
class LegalController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the current version of a legal document.
|
||||||
|
*/
|
||||||
|
public function show(string $type): View
|
||||||
|
{
|
||||||
|
$document = LegalDocument::query()
|
||||||
|
->where('type', $type)
|
||||||
|
->where('is_current', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
return view('legal.show', [
|
||||||
|
'document' => $document,
|
||||||
|
'title' => $document->title[$locale] ?? ($document->title['pt_BR'] ?? $document->type),
|
||||||
|
'body' => $document->body[$locale] ?? ($document->body['pt_BR'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the first-access terms acceptance screen.
|
||||||
|
*/
|
||||||
|
public function acceptForm(Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$current = LegalDocument::query()->where('is_current', true)->orderBy('type')->get();
|
||||||
|
|
||||||
|
if ($current->isEmpty()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||||
|
$pending = $current->reject(fn (LegalDocument $doc) => $acceptedIds->contains($doc->id));
|
||||||
|
|
||||||
|
if ($pending->isEmpty()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$documents = $pending->map(fn (LegalDocument $doc): array => [
|
||||||
|
'type' => $doc->type,
|
||||||
|
'version' => $doc->version,
|
||||||
|
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
|
||||||
|
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
|
||||||
|
])->values();
|
||||||
|
|
||||||
|
return view('legal.accept', [
|
||||||
|
'documents' => $documents,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the acceptance of all current legal documents.
|
||||||
|
*/
|
||||||
|
public function accept(ConsentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$documents = LegalDocument::query()->where('is_current', true)->get();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $documents, $request): void {
|
||||||
|
foreach ($documents as $document) {
|
||||||
|
UserConsent::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'legal_document_id' => $document->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'accepted_at' => now(),
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => (string) $request->userAgent(),
|
||||||
|
'source' => 'web_form',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('dashboard')->with('status', __('legal.accepted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the consent audit history of the authenticated user.
|
||||||
|
*/
|
||||||
|
public function history(Request $request): View
|
||||||
|
{
|
||||||
|
$history = $request->user()
|
||||||
|
->consents()
|
||||||
|
->with('document:id,type,version')
|
||||||
|
->orderByDesc('accepted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('legal.history', [
|
||||||
|
'history' => $history,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\LegalDocument;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks access until the user accepted all current legal documents.
|
||||||
|
*
|
||||||
|
* Implements the LGPD requirement that the Terms of Use, Privacy Policy
|
||||||
|
* and Cookie Policy must be accepted on first access before the main
|
||||||
|
* platform features can be used.
|
||||||
|
*/
|
||||||
|
class EnsureTermsAccepted
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentIds = LegalDocument::query()->where('is_current', true)->pluck('id');
|
||||||
|
|
||||||
|
if ($currentIds->isEmpty()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||||
|
$pending = $currentIds->diff($acceptedIds);
|
||||||
|
|
||||||
|
if ($pending->isNotEmpty()) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('legal.accept_required'),
|
||||||
|
'pending' => $pending->values(),
|
||||||
|
], 428);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('terms.accept');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the authenticated account is still active.
|
||||||
|
*
|
||||||
|
* Blocks disabled (deactivated) accounts from accessing any protected
|
||||||
|
* route, even when a previously issued token or session remains valid.
|
||||||
|
*/
|
||||||
|
class EnsureUserIsActive
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user !== null && ! $user->is_active) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('auth.disabled'),
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth()->guard('web')->logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->withErrors([
|
||||||
|
'email' => __('auth.disabled'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts access to platform administrators.
|
||||||
|
*
|
||||||
|
* Returns 403 for non-admin users, protecting the admin-only endpoints
|
||||||
|
* (user management, agent registry, store registry administration).
|
||||||
|
*/
|
||||||
|
class EnsureUserIsAdmin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null || ! $user->isAdmin()) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403, __('auth.forbidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the visitor's preferred locale on each request.
|
||||||
|
*
|
||||||
|
* Reads the locale from the session (set at login or when the user switches
|
||||||
|
* language) or from the city10-locale cookie, falling back to the default.
|
||||||
|
* This keeps the whole interface in the language chosen by the user.
|
||||||
|
*/
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Supported UI locales.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const SUPPORTED = ['pt_BR', 'en', 'es'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$locale = ($request->hasSession() ? $request->session()->get('locale') : null)
|
||||||
|
?: $request->cookie('city10-locale')
|
||||||
|
?: config('app.locale');
|
||||||
|
|
||||||
|
if (in_array($locale, self::SUPPORTED, true)) {
|
||||||
|
App::setLocale($locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates creation and update payloads for AI agents.
|
||||||
|
*
|
||||||
|
* Enforces the allow-list of free OpenRouter models defined in
|
||||||
|
* config/ai.php so that no paid model can ever be activated.
|
||||||
|
*/
|
||||||
|
class AiAgentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Authorization is enforced by admin role middleware in routes.
|
||||||
|
return $this->user()?->isAdmin() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$allowed = config('ai.allowed_models', []);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'type' => ['required', 'string', 'max:40'],
|
||||||
|
'model' => ['required', 'string', Rule::in($allowed)],
|
||||||
|
'system_prompt' => ['nullable', 'string', 'max:8000'],
|
||||||
|
'current_goal' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'status' => ['nullable', Rule::in(['active', 'paused', 'thinking', 'error'])],
|
||||||
|
'pos_x' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||||
|
'pos_y' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||||
|
'pos_z' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||||
|
'rotation_y' => ['nullable', 'numeric', 'between:-6.2832,6.2832'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validation errors.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'model.in' => __('validation.model_not_allowed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the submission of legal document acceptances (LGPD consent).
|
||||||
|
*/
|
||||||
|
class ConsentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'accepted_terms' => ['accepted'],
|
||||||
|
'accepted_privacy' => ['accepted'],
|
||||||
|
'accepted_cookies' => ['accepted'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validation errors.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'accepted_terms.accepted' => __('legal.must_accept_terms'),
|
||||||
|
'accepted_privacy.accepted' => __('legal.must_accept_privacy'),
|
||||||
|
'accepted_cookies.accepted' => __('legal.must_accept_cookies'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Http\Requests\Traits\ValidatesMathCaptcha;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates credentials submitted through the login form.
|
||||||
|
*/
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesMathCaptcha;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
'remember' => ['nullable', 'boolean'],
|
||||||
|
'captcha' => ['required', 'integer'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validation errors.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'captcha.required' => __('validation.captcha_required'),
|
||||||
|
'captcha.integer' => __('validation.captcha_required'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the validator instance.
|
||||||
|
*
|
||||||
|
* @param Validator $validator
|
||||||
|
*/
|
||||||
|
public function withValidator($validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function ($validator): void {
|
||||||
|
if (! $this->validateMathCaptcha()) {
|
||||||
|
$validator->errors()->add('captcha', __('validation.captcha_failed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Http\Requests\Traits\ValidatesMathCaptcha;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates new account registrations submitted through the public form.
|
||||||
|
*/
|
||||||
|
class RegisterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesMathCaptcha;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||||
|
'password' => ['required', 'confirmed', Password::min(8)],
|
||||||
|
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
|
||||||
|
'captcha' => ['required', 'integer'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the validator instance.
|
||||||
|
*
|
||||||
|
* @param Validator $validator
|
||||||
|
*/
|
||||||
|
public function withValidator($validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function ($validator): void {
|
||||||
|
if (! $this->validateMathCaptcha()) {
|
||||||
|
$validator->errors()->add('captcha', __('validation.captcha_failed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates creation and update payloads for city stores.
|
||||||
|
*/
|
||||||
|
class StoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Authorization is enforced by role middleware in the route file.
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$storeId = $this->route('store')?->id ?? $this->route('store');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:160'],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'type' => ['required', 'string', 'max:60'],
|
||||||
|
'owner_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'agent_id' => ['nullable', 'integer', Rule::exists('ai_agents', 'id')],
|
||||||
|
'pos_x' => ['required', 'numeric', 'between:-100000,100000'],
|
||||||
|
'pos_y' => ['required', 'numeric', 'between:-100000,100000'],
|
||||||
|
'pos_z' => ['required', 'numeric', 'between:-100000,100000'],
|
||||||
|
'width' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||||
|
'height' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||||
|
'depth' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||||
|
'is_open' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple arithmetic CAPTCHA implemented with pure Laravel primitives.
|
||||||
|
*
|
||||||
|
* Generates a sum of two random integers, stores the expected result in
|
||||||
|
* the session and validates the user answer. It introduces no external
|
||||||
|
* dependency and is therefore safe from third-party licensing concerns.
|
||||||
|
*/
|
||||||
|
trait ValidatesMathCaptcha
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate a new math challenge and persist it in the session.
|
||||||
|
*
|
||||||
|
* @return array{a: int, b: int, question: string}
|
||||||
|
*/
|
||||||
|
protected static function generateMathCaptcha(): array
|
||||||
|
{
|
||||||
|
$a = random_int(1, 9);
|
||||||
|
$b = random_int(1, 9);
|
||||||
|
|
||||||
|
Session::put('captcha_result', $a + $b);
|
||||||
|
// Sliding expiry: challenge is valid for 10 minutes.
|
||||||
|
Session::put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'a' => $a,
|
||||||
|
'b' => $b,
|
||||||
|
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the submitted CAPTCHA answer against the session value.
|
||||||
|
*/
|
||||||
|
protected function validateMathCaptcha(): bool
|
||||||
|
{
|
||||||
|
$expected = Session::get('captcha_result');
|
||||||
|
$expiresAt = Session::get('captcha_expires_at');
|
||||||
|
|
||||||
|
// Invalidate the challenge after it is consumed.
|
||||||
|
Session::forget(['captcha_result', 'captcha_expires_at']);
|
||||||
|
|
||||||
|
if ($expected === null || $expiresAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now()->getTimestamp() > (int) $expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->input('captcha') === (int) $expected;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\AiAgentFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autonomous AI agent entity.
|
||||||
|
*
|
||||||
|
* Drives NPCs and managers of the virtual city through the OpenRouter.ai
|
||||||
|
* API. Only allow-listed free models may be selected by administrators.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'name', 'type', 'model', 'system_prompt', 'current_goal', 'last_decision',
|
||||||
|
'status', 'error_count', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_think_at',
|
||||||
|
])]
|
||||||
|
class AiAgent extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<AiAgentFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'last_decision' => 'array',
|
||||||
|
'last_think_at' => 'datetime',
|
||||||
|
'pos_x' => 'float',
|
||||||
|
'pos_y' => 'float',
|
||||||
|
'pos_z' => 'float',
|
||||||
|
'rotation_y' => 'float',
|
||||||
|
'error_count' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores managed by this agent (shopkeepers, mayors, etc.).
|
||||||
|
*
|
||||||
|
* @return HasMany<Store, $this>
|
||||||
|
*/
|
||||||
|
public function stores(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Store::class, 'agent_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioned legal document (Terms of Use, Privacy Policy, Cookie Policy).
|
||||||
|
*
|
||||||
|
* Bodies are stored per language so that the UI can render the version
|
||||||
|
* matching the user's selected locale at acceptance time.
|
||||||
|
*/
|
||||||
|
#[Fillable(['type', 'version', 'title', 'body', 'is_current', 'published_at'])]
|
||||||
|
class LegalDocument extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Document type: Terms of Use.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const TYPE_TERMS = 'terms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document type: Privacy Policy (LGPD).
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const TYPE_PRIVACY = 'privacy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document type: Cookie Policy.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const TYPE_COOKIES = 'cookies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'array',
|
||||||
|
'body' => 'array',
|
||||||
|
'is_current' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users that accepted this document version.
|
||||||
|
*
|
||||||
|
* @return BelongsToMany<User, $this>
|
||||||
|
*/
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'user_consents')
|
||||||
|
->withPivot(['accepted_at', 'ip_address', 'user_agent', 'source'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\StoreFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical store placed inside the 3D city.
|
||||||
|
*
|
||||||
|
* Each store has an exact (x, y, z) world coordinate, footprint dimensions
|
||||||
|
* and an optional AI shopkeeper agent controlling its commercial behaviour.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'name', 'description', 'type', 'owner_id', 'agent_id',
|
||||||
|
'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth',
|
||||||
|
'is_open', 'revenue',
|
||||||
|
])]
|
||||||
|
class Store extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<StoreFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner (shopkeeper) account.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function owner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'owner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI agent controlling the store, when assigned.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<AiAgent, $this>
|
||||||
|
*/
|
||||||
|
public function agent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AiAgent::class, 'agent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pos_x' => 'float',
|
||||||
|
'pos_y' => 'float',
|
||||||
|
'pos_z' => 'float',
|
||||||
|
'width' => 'float',
|
||||||
|
'height' => 'float',
|
||||||
|
'depth' => 'float',
|
||||||
|
'revenue' => 'float',
|
||||||
|
'is_open' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application user entity.
|
||||||
|
*
|
||||||
|
* Supports role based access control (admin/user) and records the
|
||||||
|
* acceptance of versioned legal documents (Terms, Privacy, Cookies)
|
||||||
|
* required by the Brazilian LGPD.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'email', 'password', 'role', 'locale', 'is_active'])]
|
||||||
|
#[Hidden(['password', 'remember_token'])]
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserFactory> */
|
||||||
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role granted to platform administrators.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const ROLE_ADMIN = 'admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role granted to regular citizens of the virtual city.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const ROLE_USER = 'user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores owned by this user.
|
||||||
|
*
|
||||||
|
* @return HasMany<Store, $this>
|
||||||
|
*/
|
||||||
|
public function stores(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Store::class, 'owner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal consents recorded for this user.
|
||||||
|
*
|
||||||
|
* @return HasMany<UserConsent, $this>
|
||||||
|
*/
|
||||||
|
public function consents(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserConsent::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal documents this user has accepted.
|
||||||
|
*
|
||||||
|
* @return BelongsToMany<LegalDocument, $this>
|
||||||
|
*/
|
||||||
|
public function acceptedDocuments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(LegalDocument::class, 'user_consents')
|
||||||
|
->withPivot(['accepted_at', 'ip_address', 'user_agent', 'source'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user holds administrator privileges.
|
||||||
|
*/
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->role === self::ROLE_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the user accepted every current legal document version.
|
||||||
|
*/
|
||||||
|
public function hasAcceptedCurrentTerms(): bool
|
||||||
|
{
|
||||||
|
$currentIds = LegalDocument::query()->where('is_current', true)->pluck('id');
|
||||||
|
|
||||||
|
if ($currentIds->isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$acceptedIds = $this->consents()->pluck('legal_document_id');
|
||||||
|
|
||||||
|
return $currentIds->diff($acceptedIds)->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'last_login_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable audit record of a user consent event.
|
||||||
|
*
|
||||||
|
* Captures which legal document version was accepted, when, from which IP
|
||||||
|
* and through which channel, enabling full LGPD accountability.
|
||||||
|
*/
|
||||||
|
#[Fillable(['user_id', 'legal_document_id', 'accepted_at', 'ip_address', 'user_agent', 'source'])]
|
||||||
|
class UserConsent extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User that granted the consent.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal document version that was accepted.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<LegalDocument, $this>
|
||||||
|
*/
|
||||||
|
public function document(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(LegalDocument::class, 'legal_document_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application service provider.
|
||||||
|
*
|
||||||
|
* Registers and boots framework services. Locale resolution is handled by
|
||||||
|
* the SetLocale middleware so it runs after the session is started.
|
||||||
|
*/
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureTermsAccepted;
|
||||||
|
use App\Http\Middleware\EnsureUserIsActive;
|
||||||
|
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// Alias custom middleware so they can be referenced by short name.
|
||||||
|
$middleware->alias([
|
||||||
|
'active' => EnsureUserIsActive::class,
|
||||||
|
'admin' => EnsureUserIsAdmin::class,
|
||||||
|
'terms' => EnsureTermsAccepted::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply "active" (account status) and "locale" (language) to web.
|
||||||
|
$middleware->web(append: [
|
||||||
|
EnsureUserIsActive::class,
|
||||||
|
SetLocale::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The cookie-consent flag is written by client-side JS (plain value),
|
||||||
|
// so it must not be encrypted/decrypted by the framework.
|
||||||
|
$middleware->encryptCookies(except: [
|
||||||
|
'city10-consent',
|
||||||
|
'city10-locale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply "locale" to API requests too (after auth so user is known).
|
||||||
|
$middleware->api(append: [
|
||||||
|
SetLocale::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||||
|
);
|
||||||
|
})->create();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": ["laravel", "framework"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"laravel/framework": "^13.17",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
|
"laravel/tinker": "^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.2.5",
|
||||||
|
"laravel/pao": "^1.0.6",
|
||||||
|
"laravel/pint": "^1.27",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^12.5.12"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install --ignore-scripts",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@php artisan dev"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"php": "8.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| OpenRouter.ai Integration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Credentials and behaviour settings used by the AI agents to take
|
||||||
|
| strategic decisions through the OpenRouter.ai chat completions API.
|
||||||
|
| The API key must be provided via the OPENROUTER_API_KEY env variable
|
||||||
|
| and must never be committed to version control.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'openrouter' => [
|
||||||
|
'api_key' => env('OPENROUTER_API_KEY'),
|
||||||
|
'base_url' => env('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
|
||||||
|
'timeout' => (int) env('OPENROUTER_TIMEOUT', 30),
|
||||||
|
'app_name' => env('APP_NAME', 'City10'),
|
||||||
|
'app_url' => env('APP_URL', 'http://localhost:16980'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Allow-listed Free Models
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Only the free models listed below may be selected for AI agents.
|
||||||
|
| Administrators cannot register agents with models outside this list,
|
||||||
|
| which keeps operating costs at zero and enforces the platform policy.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'allowed_models' => [
|
||||||
|
'poolside/laguna-m.1:free',
|
||||||
|
'cohere/north-mini-code:free',
|
||||||
|
'deepseek/deepseek-v4-flash-0731:free',
|
||||||
|
'qwen/qwen3.8-27b:free',
|
||||||
|
'nvidia/nemotron-3-ultra-550b-a55b:free',
|
||||||
|
'nvidia/nemotron-3-super-120b-a12b:free',
|
||||||
|
'google/gemma-4-31b-it:free',
|
||||||
|
'google/gemma-4-26b-a4b-it:free',
|
||||||
|
'openai/gpt-oss-20b:free',
|
||||||
|
'nvidia/nemotron-nano-12b-v2-vl:free',
|
||||||
|
'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free',
|
||||||
|
'nvidia/nemotron-3-nano-30b-a3b:free',
|
||||||
|
'nvidia/nemotron-nano-9b-v2:free',
|
||||||
|
'poolside/laguna-xs-2.1:free',
|
||||||
|
'inclusionai/ling-3.0-flash-vl:free',
|
||||||
|
'qwen/qwen3.7-flash:free',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Model
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Model assigned to newly created agents when none is specified.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default_model' => env('AI_DEFAULT_MODEL', 'deepseek/deepseek-v4-flash-0731'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agent Decision Cadence (seconds)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Minimum interval between two reasoning calls for the same agent,
|
||||||
|
| protecting the upstream API from excessive usage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'think_interval' => (int) env('AI_THINK_INTERVAL', 60),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "storage", "octane",
|
||||||
|
| "session", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/locks'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'storage' => [
|
||||||
|
'driver' => 'storage',
|
||||||
|
'disk' => env('CACHE_STORAGE_DISK'),
|
||||||
|
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Serializable Classes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the classes that can be unserialized from cache
|
||||||
|
| storage. By default, no PHP classes will be unserialized from your
|
||||||
|
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serializable_classes' => false,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pdo\Mysql;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'max_files' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'monthly' => [
|
||||||
|
'driver' => 'monthly',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'max_files' => 3,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue supports a variety of backends via a single, unified
|
||||||
|
| API, giving you convenient access to each backend using identical
|
||||||
|
| syntax for each. The default queue connection is defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection options for every queue backend
|
||||||
|
| used by your application. An example configuration is provided for
|
||||||
|
| each backend supported by Laravel. You're also free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control how and where failed jobs are stored. Laravel ships with
|
||||||
|
| support for storing failed jobs in a simple file or in a database.
|
||||||
|
|
|
||||||
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Resend, Postmark, AWS, and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'notifications' => [
|
||||||
|
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||||
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines the default session driver that is utilized for
|
||||||
|
| incoming requests. Laravel supports a variety of storage options to
|
||||||
|
| persist session data. Database storage is a great default choice.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to expire immediately when the browser is closed then you may
|
||||||
|
| indicate that via the expire_on_close configuration option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it's stored. All encryption is performed
|
||||||
|
| automatically by Laravel and you may use the session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the "file" session driver, the session files are placed
|
||||||
|
| on disk. The default storage location is defined here; however, you
|
||||||
|
| are free to provide another location where they should be stored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table to
|
||||||
|
| be used to store sessions. Of course, a sensible default is defined
|
||||||
|
| for you; however, you're welcome to change this to another table.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => env('SESSION_TABLE', 'sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using one of the framework's cache driven session backends, you may
|
||||||
|
| define the cache store which should be used to store the session data
|
||||||
|
| between requests. This must match one of your defined cache stores.
|
||||||
|
|
|
||||||
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the session cookie that is created by
|
||||||
|
| the framework. Typically, you should not need to change this value
|
||||||
|
| since doing so does not grant a meaningful security improvement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application, but you're free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('SESSION_PATH', '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the domain and subdomains the session cookie is
|
||||||
|
| available to. By default, the cookie will be available to the root
|
||||||
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. It's unlikely you should disable this option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" to permit secure cross-site requests.
|
||||||
|
|
|
||||||
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Serialization
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the serialization strategy for session data, which
|
||||||
|
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||||
|
| objects in the session but can make an application vulnerable to
|
||||||
|
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
| Supported: "json", "php"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serialization' => 'json',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
'role' => 'user',
|
||||||
|
'locale' => 'pt_BR',
|
||||||
|
'is_active' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the account holds administrator privileges.
|
||||||
|
*/
|
||||||
|
public function admin(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'role' => 'admin',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the account has been deactivated.
|
||||||
|
*/
|
||||||
|
public function inactive(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'is_active' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cache', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->mediumText('value');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('cache_locks', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->string('owner');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cache');
|
||||||
|
Schema::dropIfExists('cache_locks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedSmallInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->integer('total_jobs');
|
||||||
|
$table->integer('pending_jobs');
|
||||||
|
$table->integer('failed_jobs');
|
||||||
|
$table->longText('failed_job_ids');
|
||||||
|
$table->mediumText('options')->nullable();
|
||||||
|
$table->integer('cancelled_at')->nullable();
|
||||||
|
$table->integer('created_at');
|
||||||
|
$table->integer('finished_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->string('connection');
|
||||||
|
$table->string('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['connection', 'queue', 'failed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
Schema::dropIfExists('job_batches');
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->text('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds role-based access control columns to the users table.
|
||||||
|
*
|
||||||
|
* Distinguishes administrators ("admin") from regular users ("user")
|
||||||
|
* so that API endpoints and the admin panel can be protected per role.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
// Role used for authorization: "admin" or "user".
|
||||||
|
$table->string('role', 20)->default('user')->after('password')->index();
|
||||||
|
|
||||||
|
// Preferred UI language: pt_BR, en, or es.
|
||||||
|
$table->string('locale', 5)->default('pt_BR')->after('role');
|
||||||
|
|
||||||
|
// Soft-delete flag so accounts can be disabled without data loss.
|
||||||
|
$table->boolean('is_active')->default(true)->after('locale');
|
||||||
|
|
||||||
|
// Timestamp of the latest successful authentication (audit aid).
|
||||||
|
$table->timestamp('last_login_at')->nullable()->after('is_active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['role', 'locale', 'is_active', 'last_login_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the "ai_agents" table.
|
||||||
|
*
|
||||||
|
* Each AI agent represents an autonomous NPC or manager entity inside the
|
||||||
|
* virtual city. Decisions are delegated to models served by OpenRouter.ai,
|
||||||
|
* restricted to an allow-list of free models configured by the administrator.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ai_agents', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Human readable label, e.g. "Prefeito", "Lojista Padaria Central".
|
||||||
|
$table->string('name', 120);
|
||||||
|
|
||||||
|
// Agent archetype: mayor, shopkeeper, pedestrian, traffic_officer, etc.
|
||||||
|
$table->string('type', 40)->index();
|
||||||
|
|
||||||
|
// Free OpenRouter model identifier used for reasoning.
|
||||||
|
$table->string('model', 120)->default('deepseek/deepseek-v4-flash-0731');
|
||||||
|
|
||||||
|
// System prompt describing the agent persona and constraints.
|
||||||
|
$table->text('system_prompt')->nullable();
|
||||||
|
|
||||||
|
// Current macro goal in natural language (persisted AI state).
|
||||||
|
$table->text('current_goal')->nullable();
|
||||||
|
|
||||||
|
// JSON snapshot of the latest decision payload returned by the model.
|
||||||
|
$table->json('last_decision')->nullable();
|
||||||
|
|
||||||
|
// Operational state: active, paused, thinking, error.
|
||||||
|
$table->string('status', 20)->default('active')->index();
|
||||||
|
|
||||||
|
// Number of consecutive failures (used for backoff/circuit breaker).
|
||||||
|
$table->unsignedInteger('error_count')->default(0);
|
||||||
|
|
||||||
|
// 3D anchor position of the agent inside the city scene.
|
||||||
|
$table->decimal('pos_x', 12, 4)->default(0);
|
||||||
|
$table->decimal('pos_y', 12, 4)->default(0);
|
||||||
|
$table->decimal('pos_z', 12, 4)->default(0);
|
||||||
|
|
||||||
|
// Rotation on the Y axis (radians) for 3D rendering.
|
||||||
|
$table->decimal('rotation_y', 10, 6)->default(0);
|
||||||
|
|
||||||
|
$table->timestamp('last_think_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['type', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ai_agents');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the "stores" table.
|
||||||
|
*
|
||||||
|
* Stores are physical shop buildings placed in the 3D city at an exact
|
||||||
|
* (x, y, z) coordinate and are operated by an optional AI shopkeeper agent.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('stores', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Store display name, e.g. "Padaria Central".
|
||||||
|
$table->string('name', 160);
|
||||||
|
|
||||||
|
// Free text description shown in the city UI.
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
|
||||||
|
// Store category: bakery, market, hardware, restaurant, etc.
|
||||||
|
$table->string('type', 60)->index();
|
||||||
|
|
||||||
|
// Owner account (regular user acting as shopkeeper).
|
||||||
|
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
|
||||||
|
// AI shopkeeper agent driving prices and decisions (optional).
|
||||||
|
$table->foreignId('agent_id')->nullable()->constrained('ai_agents')->nullOnDelete();
|
||||||
|
|
||||||
|
// 3D placement inside the virtual city scene.
|
||||||
|
$table->decimal('pos_x', 12, 4)->default(0);
|
||||||
|
$table->decimal('pos_y', 12, 4)->default(0);
|
||||||
|
$table->decimal('pos_z', 12, 4)->default(0);
|
||||||
|
|
||||||
|
// Building footprint dimensions in world units (used by the renderer).
|
||||||
|
$table->decimal('width', 10, 4)->default(4);
|
||||||
|
$table->decimal('height', 10, 4)->default(4);
|
||||||
|
$table->decimal('depth', 10, 4)->default(4);
|
||||||
|
|
||||||
|
// Commercial state of the store.
|
||||||
|
$table->boolean('is_open')->default(true)->index();
|
||||||
|
|
||||||
|
// Current revenue in virtual currency (updated by AI/players).
|
||||||
|
$table->decimal('revenue', 14, 2)->default(0);
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['pos_x', 'pos_y', 'pos_z']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stores');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates legal document and consent tables.
|
||||||
|
*
|
||||||
|
* Stores versioned legal documents (Terms of Use, Privacy Policy and Cookie
|
||||||
|
* Policy, all aligned with the Brazilian LGPD) together with an immutable
|
||||||
|
* audit trail of each user consent event.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('legal_documents', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Document kind: terms, privacy, cookies.
|
||||||
|
$table->string('type', 20)->index();
|
||||||
|
|
||||||
|
// Version identifier, e.g. "1.0.0" (incremented on edits).
|
||||||
|
$table->string('version', 20);
|
||||||
|
|
||||||
|
// Title localized per language key stored in JSON: {"pt_BR":..,"en":..,"es":..}
|
||||||
|
$table->json('title');
|
||||||
|
|
||||||
|
// Full document body per language (markdown/HTML-safe text).
|
||||||
|
$table->json('body');
|
||||||
|
|
||||||
|
// Whether this version is the one new users must accept.
|
||||||
|
$table->boolean('is_current')->default(true)->index();
|
||||||
|
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// A given type+version pair can only exist once.
|
||||||
|
$table->unique(['type', 'version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('user_consents', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('legal_document_id')->constrained('legal_documents')->cascadeOnDelete();
|
||||||
|
|
||||||
|
// Moment the user accepted this specific document version.
|
||||||
|
$table->timestamp('accepted_at');
|
||||||
|
|
||||||
|
// Client IP and user agent captured at acceptance (LGPD audit).
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
|
||||||
|
// Consent channel, e.g. "web_form", "api".
|
||||||
|
$table->string('source', 30)->default('web_form');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// One consent per user per document version (idempotent audit).
|
||||||
|
$table->unique(['user_id', 'legal_document_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_consents');
|
||||||
|
Schema::dropIfExists('legal_documents');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Store;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds a starter set of AI agents and stores for the virtual city.
|
||||||
|
*
|
||||||
|
* Provides the Mayor, a few shopkeepers and pedestrian agents together
|
||||||
|
* with representative buildings so that the 3D scene has content to
|
||||||
|
* render on first load.
|
||||||
|
*/
|
||||||
|
class CityContentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed AI agents and stores.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$shopkeeper = User::where('email', 'user@sistema.net')->first();
|
||||||
|
|
||||||
|
// --- Mayor: coordinates the city at a macro level -----------------
|
||||||
|
$mayor = AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Prefeito Augusto'],
|
||||||
|
[
|
||||||
|
'type' => 'mayor',
|
||||||
|
'model' => config('ai.default_model'),
|
||||||
|
'system_prompt' => 'Você é o Prefeito da cidade virtual City10. Tome decisões macro sobre infraestrutura, tráfego e qualidade de vida dos cidadãos.',
|
||||||
|
'current_goal' => 'Manter a cidade segura e próspera',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Shopkeepers --------------------------------------------------
|
||||||
|
$baker = AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Seu João — Padaria Central'],
|
||||||
|
[
|
||||||
|
'type' => 'shopkeeper',
|
||||||
|
'model' => config('ai.default_model'),
|
||||||
|
'system_prompt' => 'Você é o dono da Padaria Central. Gerencie estoque, preços e atendimento com simpatia.',
|
||||||
|
'current_goal' => 'Aumentar as vendas da manhã',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 12, 'pos_y' => 0, 'pos_z' => -8,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Pedestrians --------------------------------------------------
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Nara'],
|
||||||
|
[
|
||||||
|
'type' => 'pedestrian',
|
||||||
|
'model' => config('ai.default_model'),
|
||||||
|
'system_prompt' => 'Você é a Nara, uma pedestre amigável. Converse brevemente com quem se aproximar.',
|
||||||
|
'current_goal' => 'Caminhar até a praça',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 20, 'pos_y' => 0, 'pos_z' => 10,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Wilson'],
|
||||||
|
[
|
||||||
|
'type' => 'pedestrian',
|
||||||
|
'model' => config('ai.default_model'),
|
||||||
|
'system_prompt' => 'Você é o Wilson, um pedestre que trabalha na loja de material de construção.',
|
||||||
|
'current_goal' => 'Voltar para casa',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 6, 'pos_y' => 0, 'pos_z' => 4,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Amanda'],
|
||||||
|
[
|
||||||
|
'type' => 'pedestrian',
|
||||||
|
'model' => config('ai.default_model'),
|
||||||
|
'system_prompt' => 'Você é a Amanda, criadora de conteúdo. Gosta de conversar sobre arte e moda.',
|
||||||
|
'current_goal' => 'Fotografe a praça para o feed',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 30, 'pos_y' => 0, 'pos_z' => 6,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Stores -------------------------------------------------------
|
||||||
|
Store::updateOrCreate(
|
||||||
|
['name' => 'Padaria Central'],
|
||||||
|
[
|
||||||
|
'description' => 'Pães frescos e cafés desde 1998.',
|
||||||
|
'type' => 'bakery',
|
||||||
|
'owner_id' => $shopkeeper?->id,
|
||||||
|
'agent_id' => $baker->id,
|
||||||
|
'pos_x' => 12, 'pos_y' => 0, 'pos_z' => -12,
|
||||||
|
'width' => 8, 'height' => 6, 'depth' => 8,
|
||||||
|
'is_open' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Store::updateOrCreate(
|
||||||
|
['name' => 'Mercado Municipal'],
|
||||||
|
[
|
||||||
|
'description' => 'Produtos frescos e itens do dia a dia.',
|
||||||
|
'type' => 'market',
|
||||||
|
'owner_id' => $shopkeeper?->id,
|
||||||
|
'agent_id' => $mayor->id,
|
||||||
|
'pos_x' => -18, 'pos_y' => 0, 'pos_z' => 14,
|
||||||
|
'width' => 14, 'height' => 8, 'depth' => 12,
|
||||||
|
'is_open' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Store::updateOrCreate(
|
||||||
|
['name' => 'Loja de Materiais da Esquina'],
|
||||||
|
[
|
||||||
|
'description' => 'Material de construção e ferramentas.',
|
||||||
|
'type' => 'hardware',
|
||||||
|
'owner_id' => $shopkeeper?->id,
|
||||||
|
'pos_x' => 8, 'pos_y' => 0, 'pos_z' => 6,
|
||||||
|
'width' => 10, 'height' => 7, 'depth' => 10,
|
||||||
|
'is_open' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master seeder orchestrating all initial dataset loaders.
|
||||||
|
*
|
||||||
|
* Order matters: legal documents and users are created first so that
|
||||||
|
* foreign keys from agents and stores resolve correctly.
|
||||||
|
*/
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
use WithoutModelEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the application's database.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call([
|
||||||
|
LegalDocumentSeeder::class,
|
||||||
|
UserSeeder::class,
|
||||||
|
CityContentSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\LegalDocument;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the initial version (1.0.0) of the three legal documents.
|
||||||
|
*
|
||||||
|
* Content is provided in Portuguese (Brazil), English and Spanish and
|
||||||
|
* covers the requirements of the Brazilian LGPD (Lei nº 13.709/2018):
|
||||||
|
* data collection, storage, processing, sharing, deletion rights and the
|
||||||
|
* cookie policy with consent management.
|
||||||
|
*/
|
||||||
|
class LegalDocumentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed the legal documents table.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$publishedAt = now();
|
||||||
|
|
||||||
|
LegalDocument::updateOrCreate(
|
||||||
|
['type' => LegalDocument::TYPE_TERMS, 'version' => '1.0.0'],
|
||||||
|
[
|
||||||
|
'title' => [
|
||||||
|
'pt_BR' => 'Termos de Uso e Política de Privacidade',
|
||||||
|
'en' => 'Terms of Use and Privacy Policy',
|
||||||
|
'es' => 'Términos de Uso y Política de Privacidad',
|
||||||
|
],
|
||||||
|
'body' => [
|
||||||
|
'pt_BR' => $this->termsPtBr(),
|
||||||
|
'en' => $this->termsEn(),
|
||||||
|
'es' => $this->termsEs(),
|
||||||
|
],
|
||||||
|
'is_current' => true,
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
LegalDocument::updateOrCreate(
|
||||||
|
['type' => LegalDocument::TYPE_PRIVACY, 'version' => '1.0.0'],
|
||||||
|
[
|
||||||
|
'title' => [
|
||||||
|
'pt_BR' => 'Política de Privacidade (LGPD)',
|
||||||
|
'en' => 'Privacy Policy (LGPD)',
|
||||||
|
'es' => 'Política de Privacidad (LGPD)',
|
||||||
|
],
|
||||||
|
'body' => [
|
||||||
|
'pt_BR' => $this->privacyPtBr(),
|
||||||
|
'en' => $this->privacyEn(),
|
||||||
|
'es' => $this->privacyEs(),
|
||||||
|
],
|
||||||
|
'is_current' => true,
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
LegalDocument::updateOrCreate(
|
||||||
|
['type' => LegalDocument::TYPE_COOKIES, 'version' => '1.0.0'],
|
||||||
|
[
|
||||||
|
'title' => [
|
||||||
|
'pt_BR' => 'Política de Cookies',
|
||||||
|
'en' => 'Cookie Policy',
|
||||||
|
'es' => 'Política de Cookies',
|
||||||
|
],
|
||||||
|
'body' => [
|
||||||
|
'pt_BR' => $this->cookiesPtBr(),
|
||||||
|
'en' => $this->cookiesEn(),
|
||||||
|
'es' => $this->cookiesEs(),
|
||||||
|
],
|
||||||
|
'is_current' => true,
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms of Use body in Portuguese (Brazil).
|
||||||
|
*/
|
||||||
|
private function termsPtBr(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Termos de Uso — City10
|
||||||
|
|
||||||
|
**Versão 1.0.0** — Vigência a partir da data de aceite pelo usuário.
|
||||||
|
|
||||||
|
## 1. Objeto
|
||||||
|
O City10 é uma plataforma de simulação urbana 3D com agentes de inteligência artificial, operada pela Syspanel (contato@syspanel.com.br). Ao criar uma conta e utilizar a plataforma, o usuário declara ter lido e concordado com estes Termos e com a Política de Privacidade.
|
||||||
|
|
||||||
|
## 2. Cadastro e Conta
|
||||||
|
2.1. O usuário deve fornecer informações verdadeiras (nome e e-mail).
|
||||||
|
2.2. A conta é pessoal e intransferível. O usuário é responsável pela guarda de sua senha.
|
||||||
|
2.3. Contas administrativas possuem privilégios adicionais de gestão da cidade e dos agentes de IA.
|
||||||
|
|
||||||
|
## 3. Uso Aceitável
|
||||||
|
3.1. É vedado utilizar a plataforma para fins ilícitos, ofensivos ou que violem direitos de terceiros.
|
||||||
|
3.2. É vedado tentar contornar mecanismos de segurança, exceder limites de uso da API ou inserir conteúdo malicioso.
|
||||||
|
3.3. Os agentes de IA operam com modelos de linguagem de terceiros (OpenRouter.ai). As decisões simuladas por esses agentes são ficção e não constituem aconselhamento profissional.
|
||||||
|
|
||||||
|
## 4. Propriedade Intelectual
|
||||||
|
4.1. O software, marca City10 e respectivos código-fonte são propriedade da Syspanel, regidos pela Licença Proprietária anexa (LICENSE).
|
||||||
|
4.2. O conteúdo gerado pelo usuário permanece de sua titulariedade, concedendo à plataforma a licença mínima necessária para exibição.
|
||||||
|
|
||||||
|
## 5. Limitação de Responsabilidade
|
||||||
|
5.1. A plataforma é fornecida "no estado em que se encontra". A Syspanel não garante disponibilidade ininterrupta.
|
||||||
|
5.2. A Syspanel não se responsabiliza por decisões tomadas com base em simulações produzidas pelos agentes de IA.
|
||||||
|
|
||||||
|
## 6. Encerramento
|
||||||
|
6.1. A Syspanel pode suspender ou encerrar contas que violem estes Termos, mediante notificação quando possível.
|
||||||
|
6.2. O usuário pode solicitar a exclusão de seus dados a qualquer momento (direito de eliminação, art. 18, VI, LGPD).
|
||||||
|
|
||||||
|
## 7. Foro
|
||||||
|
Fica eleito o foro da comarca de Santa Rita do Sapucaí — MG, Brasil, para dirimir controvérsias decorrentes destes Termos.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms of Use body in English.
|
||||||
|
*/
|
||||||
|
private function termsEn(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Terms of Use — City10
|
||||||
|
|
||||||
|
**Version 1.0.0** — Effective from the date of user acceptance.
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
City10 is a 3D urban simulation platform with artificial intelligence agents, operated by Syspanel (contato@syspanel.com.br). By creating an account and using the platform, the user acknowledges having read and agreed to these Terms and to the Privacy Policy.
|
||||||
|
|
||||||
|
## 2. Registration and Account
|
||||||
|
2.1. Users must provide truthful information (name and email).
|
||||||
|
2.2. Accounts are personal and non-transferable. The user is responsible for safeguarding their password.
|
||||||
|
2.3. Administrative accounts hold additional privileges to manage the city and its AI agents.
|
||||||
|
|
||||||
|
## 3. Acceptable Use
|
||||||
|
3.1. Using the platform for unlawful, offensive purposes or that infringe third-party rights is prohibited.
|
||||||
|
3.2. Circumventing security mechanisms, exceeding API usage limits or injecting malicious content is prohibited.
|
||||||
|
3.3. AI agents run on third-party language models (OpenRouter.ai). Agent decisions are fictional and do not constitute professional advice.
|
||||||
|
|
||||||
|
## 4. Intellectual Property
|
||||||
|
4.1. The software, the City10 brand and its source code are property of Syspanel, governed by the proprietary License attached (LICENSE).
|
||||||
|
4.2. User-generated content remains owned by the user, who grants the platform the minimal licence required for display.
|
||||||
|
|
||||||
|
## 5. Limitation of Liability
|
||||||
|
5.1. The platform is provided "as is". Syspanel does not guarantee uninterrupted availability.
|
||||||
|
5.2. Syspanel is not liable for decisions taken based on simulations produced by AI agents.
|
||||||
|
|
||||||
|
## 6. Termination
|
||||||
|
6.1. Syspanel may suspend or terminate accounts that violate these Terms, with notice when possible.
|
||||||
|
6.2. Users may request deletion of their data at any time (right of erasure, art. 18, VI, LGPD).
|
||||||
|
|
||||||
|
## 7. Governing Law
|
||||||
|
The courts of Santa Rita do Sapucaí — MG, Brazil, are elected to settle disputes arising from these Terms.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms of Use body in Spanish.
|
||||||
|
*/
|
||||||
|
private function termsEs(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Términos de Uso — City10
|
||||||
|
|
||||||
|
**Versión 1.0.0** — Vigente desde la fecha de aceptación por el usuario.
|
||||||
|
|
||||||
|
## 1. Objeto
|
||||||
|
City10 es una plataforma de simulación urbana 3D con agentes de inteligencia artificial, operada por Syspanel (contato@syspanel.com.br). Al crear una cuenta y utilizar la plataforma, el usuario declara haber leído y aceptado estos Términos y la Política de Privacidad.
|
||||||
|
|
||||||
|
## 2. Registro y Cuenta
|
||||||
|
2.1. El usuario debe proporcionar información verídica (nombre y correo electrónico).
|
||||||
|
2.2. La cuenta es personal e intransferible. El usuario es responsable de la custodia de su contraseña.
|
||||||
|
2.3. Las cuentas administrativas poseen privilegios adicionales de gestión de la ciudad y de los agentes de IA.
|
||||||
|
|
||||||
|
## 3. Uso Aceptable
|
||||||
|
3.1. Está prohibido utilizar la plataforma con fines ilícitos, ofensivos o que violen derechos de terceros.
|
||||||
|
3.2. Está prohibido eludir mecanismos de seguridad, exceder límites de uso de la API o inyectar contenido malicioso.
|
||||||
|
3.3. Los agentes de IA operan con modelos de lenguaje de terceros (OpenRouter.ai). Las decisiones simuladas son ficción y no constituyen asesoramiento profesional.
|
||||||
|
|
||||||
|
## 4. Propiedad Intelectual
|
||||||
|
4.1. El software, la marca City10 y su código fuente son propiedad de Syspanel, regidos por la Licencia Proprietaria adjunta (LICENSE).
|
||||||
|
4.2. El contenido generado por el usuario sigue siendo de su titularidad, concediendo a la plataforma la licencia mínima necesaria para su exhibición.
|
||||||
|
|
||||||
|
## 5. Limitación de Responsabilidad
|
||||||
|
5.1. La plataforma se ofrece "tal cual". Syspanel no garantiza disponibilidad ininterrumpida.
|
||||||
|
5.2. Syspanel no se responsabiliza por decisiones tomadas en base a simulaciones producidas por los agentes de IA.
|
||||||
|
|
||||||
|
## 6. Terminación
|
||||||
|
6.1. Syspanel puede suspender o terminar cuentas que violen estos Términos, con notificación cuando sea posible.
|
||||||
|
6.2. El usuario puede solicitar la eliminación de sus datos en cualquier momento (derecho de supresión, art. 18, VI, LGPD).
|
||||||
|
|
||||||
|
## 7. Ley Aplicable
|
||||||
|
Se elige el foro de Santa Rita do Sapucaí — MG, Brasil, para resolver controversias derivadas de estos Términos.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy Policy body in Portuguese (Brazil).
|
||||||
|
*/
|
||||||
|
private function privacyPtBr(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Política de Privacidade — City10 (LGPD)
|
||||||
|
|
||||||
|
**Versão 1.0.0** — Em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||||||
|
|
||||||
|
## 1. Controlador dos Dados
|
||||||
|
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brasil.
|
||||||
|
|
||||||
|
## 2. Dados Pessoais Coletados
|
||||||
|
2.1. **Dados de cadastro:** nome, e-mail e senha (armazenada em formato irreversível — hash bcrypt).
|
||||||
|
2.2. **Dados de consentimento:** data/hora, versão dos documentos aceitos, endereço IP e user agent (auditoria LGPD).
|
||||||
|
2.3. **Dados de uso:** registros de acesso (logs) e preferência de idioma.
|
||||||
|
|
||||||
|
## 3. Finalidades e Bases Legais
|
||||||
|
3.1. **Execução do contrato** (art. 7º, V): prestação da plataforma, autenticação e funcionamento da cidade 3D.
|
||||||
|
3.2. **Consentimento** (art. 7º, I): aceite dos Termos, Política de Privacidade e Política de Cookies.
|
||||||
|
3.3. **Legítimo interesse** (art. 7º, IX): segurança, prevenção a fraudes e melhoria do serviço, com testes de balanceamento.
|
||||||
|
|
||||||
|
## 4. Compartilhamento
|
||||||
|
4.1. Os dados são compartilhados exclusivamente com: (i) provedor de infraestrutura ( hospedagem em Docker ); (ii) OpenRouter.ai, exclusivamente com prompts contendo contexto simulado da cidade — nunca dados cadastrais do usuário.
|
||||||
|
4.2. Não vendemos dados pessoais a terceiros.
|
||||||
|
|
||||||
|
## 5. Transferência Internacional
|
||||||
|
O serviço de modelos de IA (OpenRouter.ai) pode processar prompts fora do Brasil. Nesses casos, adotamos salvaguardas contratuais adequadas.
|
||||||
|
|
||||||
|
## 6. Tempo de Conservação
|
||||||
|
6.1. Dados de cadastro: enquanto a conta estiver ativa + 6 meses após encerramento.
|
||||||
|
6.2. Registros de consentimento: pelo prazo mínimo de 5 anos (art. 37, LGPD — dever de guarda de registros).
|
||||||
|
|
||||||
|
## 7. Direitos do Titular (art. 18)
|
||||||
|
O usuário pode solicitar: confirmação de tratamento; acesso; correção; anonimização, bloqueio ou eliminação; portabilidade; informação sobre compartilhamento; revogação do consentimento. Solicitações: contato@syspanel.com.br, respondidas em até 15 dias.
|
||||||
|
|
||||||
|
## 8. Segurança
|
||||||
|
Utilizamos hash de senhas (bcrypt), controle de acesso por papel (roles), tokens de API revogáveis e trilhas de auditoria.
|
||||||
|
|
||||||
|
## 9. Encarregado de Dados (DPO)
|
||||||
|
contato@syspanel.com.br — canal direto para o Encarregado pelo tratamento de dados pessoais.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy Policy body in English.
|
||||||
|
*/
|
||||||
|
private function privacyEn(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Privacy Policy — City10 (LGPD)
|
||||||
|
|
||||||
|
**Version 1.0.0** — Compliant with Brazilian Law nº 13.709/2018 (LGPD).
|
||||||
|
|
||||||
|
## 1. Data Controller
|
||||||
|
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brazil.
|
||||||
|
|
||||||
|
## 2. Personal Data Collected
|
||||||
|
2.1. **Registration data:** name, email and password (stored irreversibly as a bcrypt hash).
|
||||||
|
2.2. **Consent data:** date/time, accepted document versions, IP address and user agent (LGPD audit trail).
|
||||||
|
2.3. **Usage data:** access logs and language preference.
|
||||||
|
|
||||||
|
## 3. Purposes and Legal Bases
|
||||||
|
3.1. **Contract performance** (art. 7, V): platform delivery, authentication and 3D city operation.
|
||||||
|
3.2. **Consent** (art. 7, I): acceptance of Terms, Privacy Policy and Cookie Policy.
|
||||||
|
3.3. **Legitimate interest** (art. 7, IX): security, fraud prevention and service improvement.
|
||||||
|
|
||||||
|
## 4. Sharing
|
||||||
|
4.1. Data is shared exclusively with: (i) the infrastructure provider (Docker hosting); (ii) OpenRouter.ai, strictly with prompts containing simulated city context — never user registration data.
|
||||||
|
4.2. We do not sell personal data to third parties.
|
||||||
|
|
||||||
|
## 5. International Transfer
|
||||||
|
The AI model service (OpenRouter.ai) may process prompts outside Brazil. Adequate contractual safeguards are applied.
|
||||||
|
|
||||||
|
## 6. Retention Period
|
||||||
|
6.1. Registration data: while the account is active + 6 months after termination.
|
||||||
|
6.2. Consent records: minimum of 5 years (art. 37, LGPD — record-keeping duty).
|
||||||
|
|
||||||
|
## 7. Data Subject Rights (art. 18)
|
||||||
|
Users may request: confirmation of processing; access; correction; anonymisation, blocking or deletion; portability; information on sharing; withdrawal of consent. Requests: contato@syspanel.com.br, answered within 15 days.
|
||||||
|
|
||||||
|
## 8. Security
|
||||||
|
We use password hashing (bcrypt), role-based access control, revocable API tokens and audit trails.
|
||||||
|
|
||||||
|
## 9. Data Protection Officer (DPO)
|
||||||
|
contato@syspanel.com.br — direct channel to the officer responsible for personal data processing.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy Policy body in Spanish.
|
||||||
|
*/
|
||||||
|
private function privacyEs(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Política de Privacidad — City10 (LGPD)
|
||||||
|
|
||||||
|
**Versión 1.0.0** — En conformidad con la Ley nº 13.709/2018 (LGPD).
|
||||||
|
|
||||||
|
## 1. Controlador de los Datos
|
||||||
|
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brasil.
|
||||||
|
|
||||||
|
## 2. Datos Personales Recabados
|
||||||
|
2.1. **Datos de registro:** nombre, correo electrónico y contraseña (almmacenada de forma irreversible — hash bcrypt).
|
||||||
|
2.2. **Datos de consentimiento:** fecha/hora, versión de los documentos aceptados, dirección IP y user agent (auditoría LGPD).
|
||||||
|
2.3. **Datos de uso:** registros de acceso (logs) e idioma preferido.
|
||||||
|
|
||||||
|
## 3. Finalidades y Bases Legales
|
||||||
|
3.1. **Ejecución del contrato** (art. 7, V): prestación de la plataforma, autenticación y funcionamiento de la ciudad 3D.
|
||||||
|
3.2. **Consentimiento** (art. 7, I): aceptación de los Términos, Política de Privacidad y Política de Cookies.
|
||||||
|
3.3. **Interés legítimo** (art. 7, IX): seguridad, prevención de fraudes y mejora del servicio.
|
||||||
|
|
||||||
|
## 4. Compartición
|
||||||
|
4.1. Los datos se comparten exclusivamente con: (i) el proveedor de infraestructura (hospedaje en Docker); (ii) OpenRouter.ai, estrictamente con prompts que contienen contexto simulado de la ciudad — nunca datos del usuario.
|
||||||
|
4.2. No vendemos datos personales a terceros.
|
||||||
|
|
||||||
|
## 5. Transferencia Internacional
|
||||||
|
El servicio de modelos de IA (OpenRouter.ai) puede procesar prompts fuera de Brasil. Se aplican salvaguardas contractuales adecuadas.
|
||||||
|
|
||||||
|
## 6. Plazo de Conservación
|
||||||
|
6.1. Datos de registro: mientras la cuenta esté activa + 6 meses tras la finalización.
|
||||||
|
6.2. Registros de consentimiento: plazo mínimo de 5 años (art. 37, LGPD).
|
||||||
|
|
||||||
|
## 7. Derechos del Titular (art. 18)
|
||||||
|
El usuario puede solicitar: confirmación del tratamiento; acceso; corrección; anonimización, bloqueo o eliminación; portabilidad; información sobre compartición; revocación del consentimiento. Solicitudes: contato@syspanel.com.br, respondidas en hasta 15 días.
|
||||||
|
|
||||||
|
## 8. Seguridad
|
||||||
|
Utilizamos hash de contraseñas (bcrypt), control de acceso por rol, tokens de API revocables y rastros de auditoría.
|
||||||
|
|
||||||
|
## 9. Encargado de Datos (DPO)
|
||||||
|
contato@syspanel.com.br — canal directo con el encargado del tratamiento de datos personales.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie Policy body in Portuguese (Brazil).
|
||||||
|
*/
|
||||||
|
private function cookiesPtBr(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Política de Cookies — City10
|
||||||
|
|
||||||
|
**Versão 1.0.0**
|
||||||
|
|
||||||
|
## 1. O que são cookies
|
||||||
|
Cookies são pequenos arquivos de texto armazenados no navegador durante a navegação, essenciais para o funcionamento de sessões e preferências.
|
||||||
|
|
||||||
|
## 2. Cookies Utilizados
|
||||||
|
|
||||||
|
| Cookie | Tipo | Finalidade | Duração | Consentimento |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| city10-session | Estritamente necessário | Manter a sessão autenticada (CSRF + login) | Sessão / 120 min | Isento (necessário) |
|
||||||
|
| XSRF-TOKEN | Estritamente necessário | Proteção contra ataques CSRF | Sessão | Isento (necessário) |
|
||||||
|
| city10-locale | Desempenho | Guardar o idioma escolhido pelo usuário | 1 ano | Sim |
|
||||||
|
| city10-consent | Estritamente necessário | Registrar a versão dos termos aceitos | 1 ano | Isento (necessário) |
|
||||||
|
| city10-analytics | Analítico | Métricas agregadas de uso (futuro) | 90 dias | Sim |
|
||||||
|
|
||||||
|
## 3. Gestão do Consentimento
|
||||||
|
3.1. Ao acessar a plataforma, o usuário visualiza o banner de cookies com opções de **Aceitar todos**, **Recusar opcionais** e **Personalizar**.
|
||||||
|
3.2. Cookies estritamente necessários não podem ser desativados, pois sem eles a sessão e a proteção CSRF deixam de funcionar.
|
||||||
|
3.3. A escolha é armazenada no cookie `city10-consent` e pode ser alterada a qualquer momento em **Preferências de Privacidade** no dashboard.
|
||||||
|
|
||||||
|
## 4. Base Legal
|
||||||
|
Tratamento de cookies não necessários ocorre com base no consentimento (art. 7º, I, LGPD e Marco Civil da Internet, Lei nº 12.965/2014).
|
||||||
|
|
||||||
|
## 5. Exclusão
|
||||||
|
O usuário pode remover os cookies a qualquer momento pelas configurações do navegador, ciente de que recursos personalizados voltarão aos padrões.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie Policy body in English.
|
||||||
|
*/
|
||||||
|
private function cookiesEn(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Cookie Policy — City10
|
||||||
|
|
||||||
|
**Version 1.0.0**
|
||||||
|
|
||||||
|
## 1. What cookies are
|
||||||
|
Cookies are small text files stored in the browser during navigation, essential for session handling and preferences.
|
||||||
|
|
||||||
|
## 2. Cookies Used
|
||||||
|
|
||||||
|
| Cookie | Type | Purpose | Duration | Consent |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| city10-session | Strictly necessary | Keep the authenticated session (CSRF + login) | Session / 120 min | Exempt (necessary) |
|
||||||
|
| XSRF-TOKEN | Strictly necessary | CSRF attack protection | Session | Exempt (necessary) |
|
||||||
|
| city10-locale | Performance | Store the user's chosen language | 1 year | Yes |
|
||||||
|
| city10-consent | Strictly necessary | Record the accepted terms version | 1 year | Exempt (necessary) |
|
||||||
|
| city10-analytics | Analytics | Aggregated usage metrics (future) | 90 days | Yes |
|
||||||
|
|
||||||
|
## 3. Consent Management
|
||||||
|
3.1. On first access the user sees the cookie banner with **Accept all**, **Reject optional** and **Customise** options.
|
||||||
|
3.2. Strictly necessary cookies cannot be disabled because sessions and CSRF protection depend on them.
|
||||||
|
3.3. The choice is stored in the `city10-consent` cookie and can be changed at any time under **Privacy Preferences** in the dashboard.
|
||||||
|
|
||||||
|
## 4. Legal Basis
|
||||||
|
Processing of non-essential cookies relies on consent (art. 7, I, LGPD and the Brazilian Internet Civil Framework, Law nº 12.965/2014).
|
||||||
|
|
||||||
|
## 5. Deletion
|
||||||
|
Users may remove cookies at any time through browser settings, understanding that customised features will return to defaults.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie Policy body in Spanish.
|
||||||
|
*/
|
||||||
|
private function cookiesEs(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
# Política de Cookies — City10
|
||||||
|
|
||||||
|
**Versión 1.0.0**
|
||||||
|
|
||||||
|
## 1. Qué son las cookies
|
||||||
|
Las cookies son pequeños archivos de texto almacenados en el navegador durante la navegación, esenciales para el funcionamiento de sesiones y preferencias.
|
||||||
|
|
||||||
|
## 2. Cookies Utilizadas
|
||||||
|
|
||||||
|
| Cookie | Tipo | Finalidad | Duración | Consentimiento |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| city10-session | Estrictamente necesario | Mantener la sesión autenticada (CSRF + login) | Sesión / 120 min | Exento (necesario) |
|
||||||
|
| XSRF-TOKEN | Estrictamente necesario | Protección contra ataques CSRF | Sesión | Exento (necesario) |
|
||||||
|
| city10-locale | Rendimiento | Guardar el idioma elegido por el usuario | 1 año | Sí |
|
||||||
|
| city10-consent | Estrictamente necesario | Registrar la versión de los términos aceptados | 1 año | Exento (necesario) |
|
||||||
|
| city10-analytics | Analítico | Métricas agregadas de uso (futuro) | 90 días | Sí |
|
||||||
|
|
||||||
|
## 3. Gestión del Consentimiento
|
||||||
|
3.1. Al acceder a la plataforma, el usuario visualiza el banner de cookies con opciones de **Aceptar todos**, **Rechazar opcionales** y **Personalizar**.
|
||||||
|
3.2. Las cookies estrictamente necesarias no pueden desactivarse, ya que sin ellas la sesión y la protección CSRF dejan de funcionar.
|
||||||
|
3.3. La elección se almacena en la cookie `city10-consent` y puede modificarse en **Preferencias de Privacidad** del dashboard.
|
||||||
|
|
||||||
|
## 4. Base Legal
|
||||||
|
El tratamiento de cookies no necesarias se basa en el consentimiento (art. 7, I, LGPD y Marco Civil de Internet, Ley nº 12.965/2014).
|
||||||
|
|
||||||
|
## 5. Eliminación
|
||||||
|
El usuario puede eliminar las cookies en cualquier momento desde la configuración del navegador, sabiendo que las funciones personalizadas volverán a los valores predeterminados.
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the initial platform accounts.
|
||||||
|
*
|
||||||
|
* Creates the administrator and a regular test user with the credentials
|
||||||
|
* provided for the development environment. Passwords are stored using
|
||||||
|
* Laravel's bcrypt hashing through the model's "hashed" cast.
|
||||||
|
*/
|
||||||
|
class UserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed the users table.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => 'marcocosta@gmx.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Marco Costa',
|
||||||
|
'password' => 'Mega886ax',
|
||||||
|
'role' => User::ROLE_ADMIN,
|
||||||
|
'locale' => 'pt_BR',
|
||||||
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => 'user@sistema.net'],
|
||||||
|
[
|
||||||
|
'name' => 'Usuário de Testes',
|
||||||
|
'password' => 'User123',
|
||||||
|
'role' => User::ROLE_USER,
|
||||||
|
'locale' => 'pt_BR',
|
||||||
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"Login": "Log in",
|
||||||
|
"Logout": "Log out",
|
||||||
|
"Register": "Register",
|
||||||
|
"Dashboard": "Dashboard",
|
||||||
|
"Email": "Email",
|
||||||
|
"Password": "Password",
|
||||||
|
"Password Confirmation": "Confirm Password",
|
||||||
|
"Remember Me": "Remember me",
|
||||||
|
"Name": "Name",
|
||||||
|
"Save": "Save",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"Create": "Create",
|
||||||
|
"Search": "Search",
|
||||||
|
"Back": "Back",
|
||||||
|
"Welcome": "Welcome to City10",
|
||||||
|
"auth.failed": "These credentials do not match our records.",
|
||||||
|
"auth.disabled": "This account has been disabled. Please contact an administrator.",
|
||||||
|
"auth.forbidden": "You do not have permission to access this resource.",
|
||||||
|
"auth.logged_out": "You have been logged out successfully.",
|
||||||
|
"auth.throttle": "Too many attempts. Please try again in :seconds seconds.",
|
||||||
|
"legal.accept_required": "You must accept the Terms of Use and Privacy Policy before continuing.",
|
||||||
|
"legal.accepted": "Terms accepted successfully.",
|
||||||
|
"legal.must_accept_terms": "You must accept the Terms of Use.",
|
||||||
|
"legal.must_accept_privacy": "You must accept the Privacy Policy.",
|
||||||
|
"legal.must_accept_cookies": "You must accept the Cookie Policy.",
|
||||||
|
"validation.captcha_required": "Please solve the math verification.",
|
||||||
|
"validation.captcha_failed": "The math verification answer is incorrect.",
|
||||||
|
"validation.model_not_allowed": "The selected AI model is not in the allowed list.",
|
||||||
|
"users.cannot_deactivate_self": "You cannot deactivate your own account.",
|
||||||
|
"users.deactivated": "Account deactivated successfully.",
|
||||||
|
"stores.deleted": "Store deleted successfully.",
|
||||||
|
"agents.deleted": "Agent deleted successfully.",
|
||||||
|
"welcome.tagline": "A living 3D city in your browser, with autonomous AI agents — Mayor, pedestrians, shopkeepers and vehicles — deciding in real time through OpenRouter.ai.",
|
||||||
|
"welcome.card1.title": "🌐 Real-Time 3D City",
|
||||||
|
"welcome.card1.desc": "Explore streets, avenues, bridges, rivers, squares and buildings rendered with Three.js right in your browser.",
|
||||||
|
"welcome.card2.title": "🤖 AI Agents",
|
||||||
|
"welcome.card2.desc": "The mayor, pedestrians and shopkeepers make strategic decisions using free OpenRouter.ai models.",
|
||||||
|
"welcome.card3.title": "🔒 Secure & LGPD",
|
||||||
|
"welcome.card3.desc": "Differentiated authentication (Admin/User), math CAPTCHA and auditable terms consent compliant with Brazilian law.",
|
||||||
|
"welcome.city.title": "🗺️ City Elements (Phase 1)",
|
||||||
|
"welcome.city.desc": "The database already models houses, buildings, schools, parks, squares, traffic lights, streets, avenues, bridges, airports, rivers, industries, stores and shopkeepers — all with 3D coordinates (X, Y, Z) ready for the rendering engine.",
|
||||||
|
"welcome.no_account": "Don't have an account?",
|
||||||
|
"welcome.has_account": "Already have an account?",
|
||||||
|
"nav.city": "3D City",
|
||||||
|
"nav.admin": "Admin",
|
||||||
|
"dashboard.open_viewer": "Open 3D viewer",
|
||||||
|
"dashboard.viewer_desc": "Explore the city ground, stores and agents with a controllable camera.",
|
||||||
|
"dashboard.stores_title": "Stores",
|
||||||
|
"dashboard.stores_desc": "Stores registered in the 3D city.",
|
||||||
|
"dashboard.legal_title": "Consents",
|
||||||
|
"dashboard.consent_history": "View acceptance history",
|
||||||
|
"legal.terms_label": "Terms",
|
||||||
|
"legal.privacy_label": "Privacy",
|
||||||
|
"legal.cookies_label": "Cookies",
|
||||||
|
"admin.stores.title": "Manage Stores",
|
||||||
|
"admin.stores.manage": "Manage stores",
|
||||||
|
"admin.stores.admin_desc": "Create stores and define their 3D position in the city.",
|
||||||
|
"admin.stores.new": "New store",
|
||||||
|
"admin.stores.created": "Store created successfully.",
|
||||||
|
"admin.stores.updated": "Store updated successfully.",
|
||||||
|
"admin.stores.edit_title": "Edit store",
|
||||||
|
"admin.stores.create_title": "New store",
|
||||||
|
"admin.stores.empty": "No stores registered yet.",
|
||||||
|
"admin.stores.position": "3D position in the city",
|
||||||
|
"admin.stores.position_hint": "Click the ground to set X/Z, or type the coordinates. Use controls to rotate/zoom.",
|
||||||
|
"admin.stores.pos_x": "Position X",
|
||||||
|
"admin.stores.pos_y": "Height Y (ground = 0)",
|
||||||
|
"admin.stores.pos_z": "Position Z",
|
||||||
|
"admin.stores.dimensions": "Building dimensions",
|
||||||
|
"admin.stores.width": "Width",
|
||||||
|
"admin.stores.height": "Height",
|
||||||
|
"admin.stores.depth": "Depth",
|
||||||
|
"admin.stores.type": "Store type",
|
||||||
|
"admin.stores.description": "Description",
|
||||||
|
"admin.stores.owner": "Owner (user)",
|
||||||
|
"admin.stores.agent": "AI agent (shopkeeper)",
|
||||||
|
"admin.stores.is_open": "Store open",
|
||||||
|
"admin.stores.no_owner": "No owner",
|
||||||
|
"admin.stores.no_agent": "No agent",
|
||||||
|
"admin.stores.actions": "Actions",
|
||||||
|
"admin.stores.coords": "Coordinates",
|
||||||
|
"admin.stores.confirm_delete": "Delete this store from the city?",
|
||||||
|
"city.title": "3D City Viewer",
|
||||||
|
"city.loading": "Loading city…",
|
||||||
|
"city.error": "Failed to load city data.",
|
||||||
|
"city.stores_count": "stores",
|
||||||
|
"city.agents_count": "agents",
|
||||||
|
"city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit",
|
||||||
|
"city.refresh": "Reload",
|
||||||
|
"city.selected": "Selected",
|
||||||
|
"city.open": "Open",
|
||||||
|
"city.closed": "Closed",
|
||||||
|
"city.hint_fps": "WASD to walk · Mouse to look · Esc to exit · Click an NPC to chat",
|
||||||
|
"city.position": "POSITION",
|
||||||
|
"city.on_ground": "on ground",
|
||||||
|
"city.triangles": "collision tris",
|
||||||
|
"city.chat_placeholder": "Type your message…",
|
||||||
|
"city.chat_thinking": "thinking…",
|
||||||
|
"city.chat_responding": "responding…",
|
||||||
|
"city.chat_accepted": "Message sent to agent.",
|
||||||
|
"city.chat_error": "Sorry, I can't reply right now. Please try again.",
|
||||||
|
"city.chat_send": "Send",
|
||||||
|
"city.chat_with": "Chatting with"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"Login": "Iniciar sesión",
|
||||||
|
"Logout": "Cerrar sesión",
|
||||||
|
"Register": "Registrarse",
|
||||||
|
"Dashboard": "Panel",
|
||||||
|
"Email": "Correo electrónico",
|
||||||
|
"Password": "Contraseña",
|
||||||
|
"Password Confirmation": "Confirmar contraseña",
|
||||||
|
"Remember Me": "Recuérdame",
|
||||||
|
"Name": "Nombre",
|
||||||
|
"Save": "Guardar",
|
||||||
|
"Cancel": "Cancelar",
|
||||||
|
"Delete": "Eliminar",
|
||||||
|
"Edit": "Editar",
|
||||||
|
"Create": "Crear",
|
||||||
|
"Search": "Buscar",
|
||||||
|
"Back": "Volver",
|
||||||
|
"Welcome": "Bienvenido a City10",
|
||||||
|
"auth.failed": "Las credenciales proporcionadas no coinciden con nuestros registros.",
|
||||||
|
"auth.disabled": "Esta cuenta ha sido desactivada. Contacte al administrador.",
|
||||||
|
"auth.forbidden": "No tiene permiso para acceder a este recurso.",
|
||||||
|
"auth.logged_out": "Sesión cerrada exitosamente.",
|
||||||
|
"auth.throttle": "Demasiados intentos. Intente de nuevo en :seconds segundos.",
|
||||||
|
"legal.accept_required": "Debe aceptar los Términos de Uso y la Política de Privacidad antes de continuar.",
|
||||||
|
"legal.accepted": "Términos aceptados exitosamente.",
|
||||||
|
"legal.must_accept_terms": "Debe aceptar los Términos de Uso.",
|
||||||
|
"legal.must_accept_privacy": "Debe aceptar la Política de Privacidad.",
|
||||||
|
"legal.must_accept_cookies": "Debe aceptar la Política de Cookies.",
|
||||||
|
"validation.captcha_required": "Resuelva la verificación matemática.",
|
||||||
|
"validation.captcha_failed": "La respuesta de la verificación matemática es incorrecta.",
|
||||||
|
"validation.model_not_allowed": "El modelo de IA seleccionado no está en la lista de permitidos.",
|
||||||
|
"users.cannot_deactivate_self": "No puede desactivar su propia cuenta.",
|
||||||
|
"users.deactivated": "Cuenta desactivada exitosamente.",
|
||||||
|
"stores.deleted": "Tienda eliminada exitosamente.",
|
||||||
|
"agents.deleted": "Agente eliminado exitosamente.",
|
||||||
|
"welcome.tagline": "Una ciudad 3D viva en tu navegador, con agentes de IA autónomos — Alcalde, peatones, comerciantes y vehículos — decidiendo en tiempo real a través de OpenRouter.ai.",
|
||||||
|
"welcome.card1.title": "🌐 Ciudad 3D en Tiempo Real",
|
||||||
|
"welcome.card1.desc": "Explora calles, avenidas, puentes, ríos, plazas y edificios renderizados con Three.js directamente en tu navegador.",
|
||||||
|
"welcome.card2.title": "🤖 Agentes de IA",
|
||||||
|
"welcome.card2.desc": "El alcalde, los peatones y los comerciantes toman decisiones estratégicas usando modelos gratuitos de OpenRouter.ai.",
|
||||||
|
"welcome.card3.title": "🔒 Seguro y LGPD",
|
||||||
|
"welcome.card3.desc": "Autenticación diferenciada (Admin/User), CAPTCHA matemático y consentimiento auditado de términos conforme la legislación brasileña.",
|
||||||
|
"welcome.city.title": "🗺️ Elementos de la Ciudad (Fase 1)",
|
||||||
|
"welcome.city.desc": "La base de datos ya modela casas, edificios, escuelas, parques, plazas, semáforos, calles, avenidas, puentes, aeropuertos, ríos, industrias, tiendas y comerciantes — todos con coordenadas 3D (X, Y, Z) listas para el motor de renderizado.",
|
||||||
|
"welcome.no_account": "¿No tienes cuenta?",
|
||||||
|
"welcome.has_account": "¿Ya tienes cuenta?",
|
||||||
|
"nav.city": "Ciudad 3D",
|
||||||
|
"nav.admin": "Administración",
|
||||||
|
"dashboard.open_viewer": "Abrir visor 3D",
|
||||||
|
"dashboard.viewer_desc": "Explora el suelo de la ciudad, tiendas y agentes con cámara controlable.",
|
||||||
|
"dashboard.stores_title": "Tiendas",
|
||||||
|
"dashboard.stores_desc": "Tiendas registradas en la ciudad 3D.",
|
||||||
|
"dashboard.legal_title": "Consentimientos",
|
||||||
|
"dashboard.consent_history": "Ver historial de aceptaciones",
|
||||||
|
"legal.terms_label": "Términos",
|
||||||
|
"legal.privacy_label": "Privacidad",
|
||||||
|
"legal.cookies_label": "Cookies",
|
||||||
|
"admin.stores.title": "Gestionar Tiendas",
|
||||||
|
"admin.stores.manage": "Gestionar tiendas",
|
||||||
|
"admin.stores.admin_desc": "Crea tiendas y define su posición 3D en la ciudad.",
|
||||||
|
"admin.stores.new": "Nueva tienda",
|
||||||
|
"admin.stores.created": "Tienda creada exitosamente.",
|
||||||
|
"admin.stores.updated": "Tienda actualizada exitosamente.",
|
||||||
|
"admin.stores.edit_title": "Editar tienda",
|
||||||
|
"admin.stores.create_title": "Nueva tienda",
|
||||||
|
"admin.stores.empty": "Aún no hay tiendas registradas.",
|
||||||
|
"admin.stores.position": "Posición 3D en la ciudad",
|
||||||
|
"admin.stores.position_hint": "Haz clic en el suelo para fijar X/Z o escribe las coordenadas. Usa los controles para rotar/zoom.",
|
||||||
|
"admin.stores.pos_x": "Posición X",
|
||||||
|
"admin.stores.pos_y": "Altura Y (suelo = 0)",
|
||||||
|
"admin.stores.pos_z": "Posición Z",
|
||||||
|
"admin.stores.dimensions": "Dimensiones del edificio",
|
||||||
|
"admin.stores.width": "Ancho",
|
||||||
|
"admin.stores.height": "Alto",
|
||||||
|
"admin.stores.depth": "Profundidad",
|
||||||
|
"admin.stores.type": "Tipo de tienda",
|
||||||
|
"admin.stores.description": "Descripción",
|
||||||
|
"admin.stores.owner": "Dueño (usuario)",
|
||||||
|
"admin.stores.agent": "Agente de IA (comerciante)",
|
||||||
|
"admin.stores.is_open": "Tienda abierta",
|
||||||
|
"admin.stores.no_owner": "Sin dueño",
|
||||||
|
"admin.stores.no_agent": "Sin agente",
|
||||||
|
"admin.stores.actions": "Acciones",
|
||||||
|
"admin.stores.coords": "Coordenadas",
|
||||||
|
"admin.stores.confirm_delete": "¿Eliminar esta tienda de la ciudad?",
|
||||||
|
"city.title": "Visor 3D de la Ciudad",
|
||||||
|
"city.loading": "Cargando ciudad…",
|
||||||
|
"city.error": "Error al cargar los datos de la ciudad.",
|
||||||
|
"city.stores_count": "tiendas",
|
||||||
|
"city.agents_count": "agentes",
|
||||||
|
"city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir",
|
||||||
|
"city.refresh": "Recargar",
|
||||||
|
"city.selected": "Seleccionada",
|
||||||
|
"city.open": "Abierta",
|
||||||
|
"city.closed": "Cerrada",
|
||||||
|
"city.hint_fps": "WASD para caminar · Ratón para mirar · Esc para salir · Clic en un NPC para chatear",
|
||||||
|
"city.position": "POSICIÓN",
|
||||||
|
"city.on_ground": "en el suelo",
|
||||||
|
"city.triangles": "tris de colisión",
|
||||||
|
"city.chat_placeholder": "Escribe tu mensaje…",
|
||||||
|
"city.chat_thinking": "pensando…",
|
||||||
|
"city.chat_responding": "respondiendo…",
|
||||||
|
"city.chat_accepted": "Mensaje enviado al agente.",
|
||||||
|
"city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.",
|
||||||
|
"city.chat_send": "Enviar",
|
||||||
|
"city.chat_with": "Chateando con"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"Login": "Entrar",
|
||||||
|
"Logout": "Sair",
|
||||||
|
"Register": "Cadastrar",
|
||||||
|
"Dashboard": "Painel",
|
||||||
|
"Email": "E-mail",
|
||||||
|
"Password": "Senha",
|
||||||
|
"Password Confirmation": "Confirmar Senha",
|
||||||
|
"Remember Me": "Lembrar de mim",
|
||||||
|
"Name": "Nome",
|
||||||
|
"Save": "Salvar",
|
||||||
|
"Cancel": "Cancelar",
|
||||||
|
"Delete": "Excluir",
|
||||||
|
"Edit": "Editar",
|
||||||
|
"Create": "Criar",
|
||||||
|
"Search": "Buscar",
|
||||||
|
"Back": "Voltar",
|
||||||
|
"Welcome": "Bem-vindo ao City10",
|
||||||
|
"auth.failed": "As credenciais fornecidas não correspondem aos nossos registros.",
|
||||||
|
"auth.disabled": "Esta conta foi desativada. Contate o administrador.",
|
||||||
|
"auth.forbidden": "Você não tem permissão para acessar este recurso.",
|
||||||
|
"auth.logged_out": "Sessão encerrada com sucesso.",
|
||||||
|
"auth.throttle": "Muitas tentativas. Tente novamente em :seconds segundos.",
|
||||||
|
"legal.accept_required": "Você precisa aceitar os Termos de Uso e a Política de Privacidade antes de continuar.",
|
||||||
|
"legal.accepted": "Termos aceitos com sucesso.",
|
||||||
|
"legal.must_accept_terms": "Você precisa aceitar os Termos de Uso.",
|
||||||
|
"legal.must_accept_privacy": "Você precisa aceitar a Política de Privacidade.",
|
||||||
|
"legal.must_accept_cookies": "Você precisa aceitar a Política de Cookies.",
|
||||||
|
"validation.captcha_required": "Resolva a verificação matemática.",
|
||||||
|
"validation.captcha_failed": "Resposta da verificação matemática incorreta.",
|
||||||
|
"validation.model_not_allowed": "O modelo de IA selecionado não está na lista de permitidos.",
|
||||||
|
"users.cannot_deactivate_self": "Você não pode desativar sua própria conta.",
|
||||||
|
"users.deactivated": "Conta desativada com sucesso.",
|
||||||
|
"stores.deleted": "Loja excluída com sucesso.",
|
||||||
|
"agents.deleted": "Agente excluído com sucesso.",
|
||||||
|
"welcome.tagline": "Uma cidade 3D viva no navegador, com agentes de IA autônomos — Prefeito, pedestres, lojistas e veículos — decidindo em tempo real através do OpenRouter.ai.",
|
||||||
|
"welcome.card1.title": "🌐 Cidade 3D em Tempo Real",
|
||||||
|
"welcome.card1.desc": "Explore ruas, avenidas, pontes, rios, praças e edifícios renderizados com Three.js direto no navegador.",
|
||||||
|
"welcome.card2.title": "🤖 Agentes de IA",
|
||||||
|
"welcome.card2.desc": "Prefeito, pedestres e lojistas tomam decisões estratégicas através de modelos gratuitos do OpenRouter.ai.",
|
||||||
|
"welcome.card3.title": "🔒 Seguro & LGPD",
|
||||||
|
"welcome.card3.desc": "Autenticação diferenciada (Admin/User), CAPTCHA matemático e consentimento auditável de termos conforme a legislação brasileira.",
|
||||||
|
"welcome.city.title": "🗺️ Elementos da Cidade (Fase 1)",
|
||||||
|
"welcome.city.desc": "A base de dados já modela casas, edifícios, escolas, parques, praças, semáforos, ruas, avenidas, pontes, aeroportos, rios, indústrias, lojas e lojistas — todos com coordenadas 3D (X, Y, Z) prontas para o motor de renderização.",
|
||||||
|
"welcome.no_account": "Não tem conta?",
|
||||||
|
"welcome.has_account": "Já tem conta?",
|
||||||
|
"nav.city": "Cidade 3D",
|
||||||
|
"nav.admin": "Administração",
|
||||||
|
"dashboard.open_viewer": "Abrir visualizador 3D",
|
||||||
|
"dashboard.viewer_desc": "Explore o chão da cidade, lojas e agentes com câmera controlável.",
|
||||||
|
"dashboard.stores_title": "Lojas",
|
||||||
|
"dashboard.stores_desc": "Lojas cadastradas na cidade 3D.",
|
||||||
|
"dashboard.legal_title": "Consentimentos",
|
||||||
|
"dashboard.consent_history": "Ver histórico de aceites",
|
||||||
|
"legal.terms_label": "Termos",
|
||||||
|
"legal.privacy_label": "Privacidade",
|
||||||
|
"legal.cookies_label": "Cookies",
|
||||||
|
"admin.stores.title": "Gerenciar Lojas",
|
||||||
|
"admin.stores.manage": "Gerenciar lojas",
|
||||||
|
"admin.stores.admin_desc": "Cadastre lojas e defina a posição 3D delas na cidade.",
|
||||||
|
"admin.stores.new": "Nova loja",
|
||||||
|
"admin.stores.created": "Loja criada com sucesso.",
|
||||||
|
"admin.stores.updated": "Loja atualizada com sucesso.",
|
||||||
|
"admin.stores.edit_title": "Editar loja",
|
||||||
|
"admin.stores.create_title": "Nova loja",
|
||||||
|
"admin.stores.empty": "Nenhuma loja cadastrada ainda.",
|
||||||
|
"admin.stores.position": "Posição 3D na cidade",
|
||||||
|
"admin.stores.position_hint": "Clique no chão para definir X/Z, ou digite as coordenadas. Use os controles para girar/zoom.",
|
||||||
|
"admin.stores.pos_x": "Posição X",
|
||||||
|
"admin.stores.pos_y": "Altura Y (chão = 0)",
|
||||||
|
"admin.stores.pos_z": "Posição Z",
|
||||||
|
"admin.stores.dimensions": "Dimensões do prédio",
|
||||||
|
"admin.stores.width": "Largura",
|
||||||
|
"admin.stores.height": "Altura",
|
||||||
|
"admin.stores.depth": "Profundidade",
|
||||||
|
"admin.stores.type": "Tipo de loja",
|
||||||
|
"admin.stores.description": "Descrição",
|
||||||
|
"admin.stores.owner": "Dono (usuário)",
|
||||||
|
"admin.stores.agent": "Agente de IA (lojista)",
|
||||||
|
"admin.stores.is_open": "Loja aberta",
|
||||||
|
"admin.stores.no_owner": "Sem dono",
|
||||||
|
"admin.stores.no_agent": "Sem agente",
|
||||||
|
"admin.stores.actions": "Ações",
|
||||||
|
"admin.stores.coords": "Coordenadas",
|
||||||
|
"admin.stores.confirm_delete": "Excluir esta loja da cidade?",
|
||||||
|
"city.title": "Visualizador 3D da Cidade",
|
||||||
|
"city.loading": "Carregando cidade…",
|
||||||
|
"city.error": "Falha ao carregar os dados da cidade.",
|
||||||
|
"city.stores_count": "lojas",
|
||||||
|
"city.agents_count": "agentes",
|
||||||
|
"city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair",
|
||||||
|
"city.refresh": "Recarregar",
|
||||||
|
"city.selected": "Selecionada",
|
||||||
|
"city.open": "Aberta",
|
||||||
|
"city.closed": "Fechada",
|
||||||
|
"city.hint_fps": "WASD para andar · Mouse para olhar · Esc para sair · Clique no NPC para conversar",
|
||||||
|
"city.position": "POSIÇÃO",
|
||||||
|
"city.on_ground": "no chão",
|
||||||
|
"city.triangles": "tris de colisão",
|
||||||
|
"city.chat_placeholder": "Digite sua mensagem…",
|
||||||
|
"city.chat_thinking": "pensando…",
|
||||||
|
"city.chat_responding": "respondendo…",
|
||||||
|
"city.chat_accepted": "Mensagem enviada ao agente.",
|
||||||
|
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
|
||||||
|
"city.chat_send": "Enviar",
|
||||||
|
"city.chat_with": "Conversando com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
|
"laravel-vite-plugin": "^3.1",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@laravel/multiplex": "^0.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Feature">
|
||||||
|
<directory>tests/Feature</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
|
<env name="DB_URL" value=""/>
|
||||||
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Handle X-XSRF-Token Header
|
||||||
|
RewriteCond %{HTTP:x-xsrf-token} .
|
||||||
|
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Determine if the application is in maintenance mode...
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the request...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||||
|
|
||||||
|
$app->handleRequest(Request::capture());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright © 2010-2023 three.js authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
@source '../../storage/framework/views/*.php';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="max-width: 1100px; margin: 0 auto;">
|
||||||
|
<p style="margin-top:0;"><a href="{{ route('admin.stores.index') }}">← {{ __('Back') }}</a></p>
|
||||||
|
|
||||||
|
<h1 style="color:#fff; margin:.5rem 0 1.25rem;">
|
||||||
|
🏪 {{ $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ $isEdit ? route('admin.stores.update', $store) : route('admin.stores.store') }}">
|
||||||
|
@csrf
|
||||||
|
@if ($isEdit) @method('PUT') @endif
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap:1.25rem; align-items:start;">
|
||||||
|
|
||||||
|
{{-- Data form --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">{{ __('Name') }}</label>
|
||||||
|
<input id="name" type="text" name="name" value="{{ old('name', $store->name) }}" required maxlength="160">
|
||||||
|
@error('name')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="type">{{ __('admin.stores.type') }}</label>
|
||||||
|
<input id="type" type="text" name="type" value="{{ old('type', $store->type) }}" required maxlength="60"
|
||||||
|
placeholder="bakery, market, hardware, restaurant…">
|
||||||
|
@error('type')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">{{ __('admin.stores.description') }}</label>
|
||||||
|
<textarea id="description" name="description" rows="3" maxlength="2000"
|
||||||
|
style="width:100%; padding:.7rem .85rem; border-radius:.55rem; border:1px solid var(--border); background:#0b1220; color:var(--text); font-size:1rem; font-family:inherit;">{{ old('description', $store->description) }}</textarea>
|
||||||
|
@error('description')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="owner_id">{{ __('admin.stores.owner') }}</label>
|
||||||
|
<select id="owner_id" name="owner_id">
|
||||||
|
<option value="">{{ __('admin.stores.no_owner') }}</option>
|
||||||
|
@foreach ($owners as $owner)
|
||||||
|
<option value="{{ $owner->id }}" @selected((string) old('owner_id', $store->owner_id) === (string) $owner->id)>
|
||||||
|
{{ $owner->name }} ({{ $owner->email }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('owner_id')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="agent_id">{{ __('admin.stores.agent') }}</label>
|
||||||
|
<select id="agent_id" name="agent_id">
|
||||||
|
<option value="">{{ __('admin.stores.no_agent') }}</option>
|
||||||
|
@foreach ($agents as $agent)
|
||||||
|
<option value="{{ $agent->id }}" @selected((string) old('agent_id', $store->agent_id) === (string) $agent->id)>
|
||||||
|
{{ $agent->name }} ({{ $agent->type }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('agent_id')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="display:flex; align-items:center; gap:.55rem;">
|
||||||
|
<input type="checkbox" id="is_open" name="is_open" value="1"
|
||||||
|
@checked(old('is_open', $store->is_open ?? true)) style="width:1.1rem; height:1.1rem;">
|
||||||
|
<label for="is_open" style="margin:0;">{{ __('admin.stores.is_open') }}</label>
|
||||||
|
</div>
|
||||||
|
@error('is_open')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
|
||||||
|
{{-- Numeric 3D coordinates (kept in sync with the picker) --}}
|
||||||
|
<fieldset style="border:1px solid var(--border); border-radius:.7rem; padding:1rem; margin:1rem 0 0;">
|
||||||
|
<legend style="padding:0 .4rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position') }}</legend>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem;">
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="pos_x">{{ __('admin.stores.pos_x') }}</label>
|
||||||
|
<input id="pos_x" type="number" name="pos_x" step="0.1" required
|
||||||
|
value="{{ old('pos_x', $store->pos_x ?? 0) }}">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="pos_y">{{ __('admin.stores.pos_y') }}</label>
|
||||||
|
<input id="pos_y" type="number" name="pos_y" step="0.1" required
|
||||||
|
value="{{ old('pos_y', $store->pos_y ?? 0) }}">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="pos_z">{{ __('admin.stores.pos_z') }}</label>
|
||||||
|
<input id="pos_z" type="number" name="pos_z" step="0.1" required
|
||||||
|
value="{{ old('pos_z', $store->pos_z ?? 0) }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@error('pos_x')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
@error('pos_y')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
@error('pos_z')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem; margin-top:.75rem;">
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="width">{{ __('admin.stores.width') }}</label>
|
||||||
|
<input id="width" type="number" name="width" step="0.1" min="0.1"
|
||||||
|
value="{{ old('width', $store->width ?? 8) }}">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="height">{{ __('admin.stores.height') }}</label>
|
||||||
|
<input id="height" type="number" name="height" step="0.1" min="0.1"
|
||||||
|
value="{{ old('height', $store->height ?? 6) }}">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin:0;">
|
||||||
|
<label for="depth">{{ __('admin.stores.depth') }}</label>
|
||||||
|
<input id="depth" type="number" name="depth" step="0.1" min="0.1"
|
||||||
|
value="{{ old('depth', $store->depth ?? 8) }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@error('width')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
@error('height')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
@error('depth')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:.75rem; margin-top:1.25rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:1; padding:.8rem;">{{ __('Save') }}</button>
|
||||||
|
<a href="{{ route('admin.stores.index') }}" class="btn" style="padding:.8rem;">{{ __('Cancel') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 3D position picker --}}
|
||||||
|
<div class="card" style="padding:1rem;">
|
||||||
|
<p style="margin:0 0 .6rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position_hint') }}</p>
|
||||||
|
|
||||||
|
<div id="picker"
|
||||||
|
data-pos-x="{{ old('pos_x', $store->pos_x ?? 0) }}"
|
||||||
|
data-pos-y="{{ old('pos_y', $store->pos_y ?? 0) }}"
|
||||||
|
data-pos-z="{{ old('pos_z', $store->pos_z ?? 0) }}"
|
||||||
|
data-width="{{ old('width', $store->width ?? 8) }}"
|
||||||
|
data-height="{{ old('height', $store->height ?? 6) }}"
|
||||||
|
data-depth="{{ old('depth', $store->depth ?? 8) }}"
|
||||||
|
style="position:relative; width:100%; height:440px; border-radius:.75rem; overflow:hidden; background:#0b1220; border:1px solid var(--border);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="picker-readout" style="margin:.6rem 0 0; font-family:ui-monospace, monospace; font-size:.85rem; color:#7dd3fc;"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from '/vendor/three/three.module.min.js';
|
||||||
|
|
||||||
|
const host = document.getElementById('picker');
|
||||||
|
const readout = document.getElementById('picker-readout');
|
||||||
|
const fX = document.getElementById('pos_x');
|
||||||
|
const fY = document.getElementById('pos_y');
|
||||||
|
const fZ = document.getElementById('pos_z');
|
||||||
|
const fW = document.getElementById('width');
|
||||||
|
const fH = document.getElementById('height');
|
||||||
|
const fD = document.getElementById('depth');
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0b1220);
|
||||||
|
scene.fog = new THREE.Fog(0x0b1220, 80, 260);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
host.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||||
|
sun.position.set(30, 60, 20);
|
||||||
|
scene.add(sun);
|
||||||
|
|
||||||
|
// Ground plane (raycast target)
|
||||||
|
const groundMat = new THREE.MeshStandardMaterial({ color: 0x14532d, roughness: 1 });
|
||||||
|
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.name = 'ground';
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
const grid = new THREE.GridHelper(400, 80, 0x334155, 0x1f2937);
|
||||||
|
grid.position.y = 0.01;
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
// Preview box for the store being edited
|
||||||
|
const boxMat = new THREE.MeshStandardMaterial({ color: 0x0ea5e9, transparent: true, opacity: 0.85 });
|
||||||
|
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), boxMat);
|
||||||
|
scene.add(box);
|
||||||
|
|
||||||
|
// Outline wireframe for clarity
|
||||||
|
const wire = new THREE.LineSegments(
|
||||||
|
new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0xffffff })
|
||||||
|
);
|
||||||
|
scene.add(wire);
|
||||||
|
|
||||||
|
// Marker pin at origin of the box
|
||||||
|
const pin = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.5, 16, 16),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xf59e0b })
|
||||||
|
);
|
||||||
|
scene.add(pin);
|
||||||
|
|
||||||
|
// Existing stores as translucent ghosts (fetched from world data)
|
||||||
|
async function loadExistingStores() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route('city.data') }}', { headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const ghostMat = new THREE.MeshStandardMaterial({ color: 0x64748b, transparent: true, opacity: 0.4 });
|
||||||
|
(data.stores || []).forEach(s => {
|
||||||
|
const [sx, sy, sz] = s.position;
|
||||||
|
const [w, h, d] = s.size;
|
||||||
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), ghostMat);
|
||||||
|
m.position.set(sx, sy + h / 2, sz);
|
||||||
|
scene.add(m);
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
loadExistingStores();
|
||||||
|
|
||||||
|
// Camera orbit state
|
||||||
|
const target = new THREE.Vector3(0, 0, 0);
|
||||||
|
let radius = 45, theta = Math.PI / 4, phi = Math.PI / 3.2;
|
||||||
|
|
||||||
|
function updateCamera() {
|
||||||
|
const sinPhi = Math.sin(phi);
|
||||||
|
camera.position.set(
|
||||||
|
target.x + radius * sinPhi * Math.sin(theta),
|
||||||
|
target.y + radius * Math.cos(phi),
|
||||||
|
target.z + radius * sinPhi * Math.cos(theta)
|
||||||
|
);
|
||||||
|
camera.lookAt(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBox() {
|
||||||
|
const px = parseFloat(fX.value) || 0;
|
||||||
|
const py = parseFloat(fY.value) || 0;
|
||||||
|
const pz = parseFloat(fZ.value) || 0;
|
||||||
|
const w = Math.max(0.1, parseFloat(fW.value) || 1);
|
||||||
|
const h = Math.max(0.1, parseFloat(fH.value) || 1);
|
||||||
|
const d = Math.max(0.1, parseFloat(fD.value) || 1);
|
||||||
|
|
||||||
|
box.scale.set(w, h, d);
|
||||||
|
box.position.set(px, py + h / 2, pz);
|
||||||
|
wire.scale.set(w, h, d);
|
||||||
|
wire.position.copy(box.position);
|
||||||
|
pin.position.set(px, py + 0.5, pz);
|
||||||
|
readout.textContent = `X ${px.toFixed(2)} · Y ${py.toFixed(2)} · Z ${pz.toFixed(2)} | ${w.toFixed(1)}×${h.toFixed(1)}×${d.toFixed(1)}`;
|
||||||
|
updateCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync inputs -> box
|
||||||
|
[fX, fY, fZ, fW, fH, fD].forEach(el => el.addEventListener('input', syncBox));
|
||||||
|
|
||||||
|
// Raycast click on ground to set X/Z
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const mouse = new THREE.Vector2();
|
||||||
|
|
||||||
|
function pointerToGround(ev) {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const hit = raycaster.intersectObject(ground, false)[0];
|
||||||
|
if (hit) {
|
||||||
|
fX.value = hit.point.x.toFixed(2);
|
||||||
|
fZ.value = hit.point.z.toFixed(2);
|
||||||
|
syncBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbit controls (drag rotate, wheel zoom, right-drag pan)
|
||||||
|
let dragging = false, panning = false, lastX = 0, lastY = 0, moved = false;
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||||||
|
moved = false;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
if (e.button === 2 || e.shiftKey) { panning = true; }
|
||||||
|
else { dragging = true; }
|
||||||
|
renderer.domElement.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('pointermove', (e) => {
|
||||||
|
if (!dragging && !panning) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
|
||||||
|
if (dragging) {
|
||||||
|
theta -= dx * 0.005;
|
||||||
|
phi = Math.min(Math.PI * 0.49, Math.max(0.12, phi - dy * 0.005));
|
||||||
|
updateCamera();
|
||||||
|
} else if (panning) {
|
||||||
|
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
|
||||||
|
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
|
||||||
|
target.addScaledVector(right, -dx * radius * 0.0016);
|
||||||
|
target.addScaledVector(up, dy * radius * 0.0016);
|
||||||
|
updateCamera();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('pointerup', (e) => {
|
||||||
|
// Treat as click (place marker) only if it wasn't a drag/pan.
|
||||||
|
if (moved) {
|
||||||
|
dragging = panning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasPanning = panning;
|
||||||
|
dragging = panning = false;
|
||||||
|
if (!wasPanning && e.button === 0) pointerToGround(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
radius = Math.min(240, Math.max(8, radius + e.deltaY * 0.05));
|
||||||
|
updateCamera();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
// Basic touch support: 1 finger orbit, 2 finger pinch zoom
|
||||||
|
let touchDist = 0;
|
||||||
|
renderer.domElement.addEventListener('touchstart', (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
touchDist = Math.hypot(
|
||||||
|
e.touches[0].clientX - e.touches[1].clientX,
|
||||||
|
e.touches[0].clientY - e.touches[1].clientY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('touchmove', (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
const d = Math.hypot(
|
||||||
|
e.touches[0].clientX - e.touches[1].clientX,
|
||||||
|
e.touches[0].clientY - e.touches[1].clientY
|
||||||
|
);
|
||||||
|
radius = Math.min(240, Math.max(8, radius + (touchDist - d) * 0.15));
|
||||||
|
touchDist = d;
|
||||||
|
updateCamera();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
function resize() {
|
||||||
|
const w = host.clientWidth, h = host.clientHeight;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
updateCamera();
|
||||||
|
}
|
||||||
|
new ResizeObserver(resize).observe(host);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
syncBox();
|
||||||
|
|
||||||
|
(function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
form > div[style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
|
||||||
|
#picker { height: 340px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('admin.stores.title'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="max-width: 1100px; margin: 0 auto;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom:1.25rem;">
|
||||||
|
<h1 style="margin:0; color:#fff;">🏪 {{ __('admin.stores.title') }}</h1>
|
||||||
|
<a href="{{ route('admin.stores.create') }}" class="btn btn-primary">+ {{ __('admin.stores.new') }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ route('admin.stores.index') }}" class="card" style="margin-bottom:1.25rem; padding:1rem;">
|
||||||
|
<div style="display:flex; gap:.75rem; flex-wrap:wrap; align-items:flex-end;">
|
||||||
|
<div class="field" style="margin:0; flex:1; min-width:180px;">
|
||||||
|
<label for="search">{{ __('Search') }}</label>
|
||||||
|
<input id="search" type="text" name="search" value="{{ $currentSearch }}" placeholder="{{ __('Search') }}">
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin:0; min-width:160px;">
|
||||||
|
<label for="type">{{ __('admin.stores.type') }}</label>
|
||||||
|
<select id="type" name="type">
|
||||||
|
<option value="">{{ __('Cancel') }}</option>
|
||||||
|
@foreach ($types as $t)
|
||||||
|
<option value="{{ $t }}" @selected($currentType === $t)>{{ $t }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ __('Search') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if ($stores->isEmpty())
|
||||||
|
<div class="card" style="text-align:center; color:var(--muted);">{{ __('admin.stores.empty') }}</div>
|
||||||
|
@else
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:1rem;">
|
||||||
|
@foreach ($stores as $store)
|
||||||
|
<div class="card" style="padding:1.15rem;">
|
||||||
|
<div style="display:flex; justify-content:space-between; gap:.5rem; align-items:flex-start;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin:0 0 .35rem; color:#fff; font-size:1.05rem;">{{ $store->name }}</h3>
|
||||||
|
<span class="badge {{ $store->is_open ? 'badge-user' : 'badge-admin' }}">{{ $store->type }}</span>
|
||||||
|
@if (! $store->is_open)
|
||||||
|
<span class="badge" style="background:rgba(239,68,68,.2); color:#fecaca;">{{ __('city.closed') }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:var(--muted); font-size:.8rem; margin:.75rem 0 .35rem;">{{ __('admin.stores.coords') }}:</p>
|
||||||
|
<p style="margin:0; font-family:ui-monospace, monospace; font-size:.85rem; color:#7dd3fc;">
|
||||||
|
X {{ number_format($store->pos_x, 2) }} · Y {{ number_format($store->pos_y, 2) }} · Z {{ number_format($store->pos_z, 2) }}
|
||||||
|
</p>
|
||||||
|
<p style="margin:.25rem 0 0; font-family:ui-monospace, monospace; font-size:.8rem; color:var(--muted);">
|
||||||
|
{{ __('admin.stores.width') }} {{ number_format($store->width, 1) }} ·
|
||||||
|
{{ __('admin.stores.height') }} {{ number_format($store->height, 1) }} ·
|
||||||
|
{{ __('admin.stores.depth') }} {{ number_format($store->depth, 1) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color:var(--muted); font-size:.85rem; margin:.75rem 0 0;">
|
||||||
|
{{ __('admin.stores.owner') }}: {{ $store->owner?->name ?? __('admin.stores.no_owner') }}<br>
|
||||||
|
{{ __('admin.stores.agent') }}: {{ $store->agent?->name ?? __('admin.stores.no_agent') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:.5rem; margin-top:1rem;">
|
||||||
|
<a href="{{ route('admin.stores.edit', $store) }}" class="btn" style="flex:1; text-align:center;">{{ __('Edit') }}</a>
|
||||||
|
<form method="POST" action="{{ route('admin.stores.destroy', $store) }}" style="margin:0;"
|
||||||
|
onsubmit="return confirm('{{ __('admin.stores.confirm_delete') }}')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn" style="color:var(--danger); border-color:rgba(239,68,68,.5);">{{ __('Delete') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem; display:flex; justify-content:center; gap:.5rem; flex-wrap:wrap;">
|
||||||
|
{{ $stores->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('Login'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="max-width: 420px; margin: 0 auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0; color:#fff;">{{ __('Login') }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('login') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">{{ __('Email') }}</label>
|
||||||
|
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username">
|
||||||
|
@error('email')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">{{ __('Password') }}</label>
|
||||||
|
<div class="pw-wrap">
|
||||||
|
<input id="password" type="password" name="password" required autocomplete="current-password">
|
||||||
|
<button type="button" class="eye-toggle" data-eye="password" aria-label="Mostrar senha">👁️</button>
|
||||||
|
</div>
|
||||||
|
@error('password')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="display:flex; align-items:center; gap:.5rem;">
|
||||||
|
<input type="checkbox" id="remember" name="remember" value="1" @checked(old('remember'))>
|
||||||
|
<label for="remember" style="margin:0;">{{ __('Remember Me') }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="captcha">{{ $captcha['question'] }}</label>
|
||||||
|
<input id="captcha" type="number" name="captcha" required inputmode="numeric">
|
||||||
|
@error('captcha')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%; padding:.8rem;">{{ __('Login') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top:1rem; font-size:.9rem; color:var(--muted);">
|
||||||
|
{{ __('welcome.no_account') }} <a href="{{ route('register') }}">{{ __('Register') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// Eye icon toggles password visibility on the login form.
|
||||||
|
document.querySelectorAll('.eye-toggle').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var input = document.getElementById(btn.dataset.eye);
|
||||||
|
if (!input) return;
|
||||||
|
var showing = input.type === 'text';
|
||||||
|
input.type = showing ? 'password' : 'text';
|
||||||
|
btn.textContent = showing ? '👁️' : '🙈';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('Register'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="max-width: 460px; margin: 0 auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0; color:#fff;">{{ __('Register') }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('register') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">{{ __('Name') }}</label>
|
||||||
|
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus maxlength="120">
|
||||||
|
@error('name')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">{{ __('Email') }}</label>
|
||||||
|
<input id="email" type="email" name="email" value="{{ old('email') }}" required autocomplete="username">
|
||||||
|
@error('email')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">{{ __('Password') }}</label>
|
||||||
|
<div class="pw-wrap">
|
||||||
|
<input id="password" type="password" name="password" required autocomplete="new-password">
|
||||||
|
<button type="button" class="eye-toggle" data-eye="password" aria-label="Mostrar senha">👁️</button>
|
||||||
|
</div>
|
||||||
|
@error('password')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password_confirmation">{{ __('Password Confirmation') }}</label>
|
||||||
|
<div class="pw-wrap">
|
||||||
|
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password">
|
||||||
|
<button type="button" class="eye-toggle" data-eye="password_confirmation" aria-label="Confirmar senha">👁️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="locale">Idioma / Language / Idioma</label>
|
||||||
|
<select id="locale" name="locale">
|
||||||
|
<option value="pt_BR" @selected(old('locale', 'pt_BR') === 'pt_BR')>Português (Brasil)</option>
|
||||||
|
<option value="en" @selected(old('locale') === 'en')>English</option>
|
||||||
|
<option value="es" @selected(old('locale') === 'es')>Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="captcha">{{ $captcha['question'] }}</label>
|
||||||
|
<input id="captcha" type="number" name="captcha" required inputmode="numeric">
|
||||||
|
@error('captcha')<div class="error">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%; padding:.8rem;">{{ __('Register') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top:1rem; font-size:.9rem; color:var(--muted);">
|
||||||
|
{{ __('welcome.has_account') }} <a href="{{ route('login') }}">{{ __('Login') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// Eye icons toggle password visibility on the registration form.
|
||||||
|
document.querySelectorAll('.eye-toggle').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var input = document.getElementById(btn.dataset.eye);
|
||||||
|
if (!input) return;
|
||||||
|
var showing = input.type === 'text';
|
||||||
|
input.type = showing ? 'password' : 'text';
|
||||||
|
btn.textContent = showing ? '👁️' : '🙈';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -0,0 +1,995 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('city.title'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div style="max-width: 1280px; margin: 0 auto;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom:1rem;">
|
||||||
|
<h1 style="margin:0; color:#fff;">🏙️ {{ __('city.title') }}</h1>
|
||||||
|
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap;">
|
||||||
|
<span id="stats" style="color:var(--muted); font-size:.9rem;"></span>
|
||||||
|
<button type="button" id="reload" class="btn">{{ __('city.refresh') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="scene"
|
||||||
|
style="position:relative; width:100%; height:min(78vh, 720px); border-radius:1rem; overflow:hidden;
|
||||||
|
background:#0b1220; border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,.45);">
|
||||||
|
<div id="loading" style="position:absolute; inset:0; display:grid; place-items:center; color:var(--muted);">
|
||||||
|
{{ __('city.loading') }}
|
||||||
|
</div>
|
||||||
|
<div id="error" style="position:absolute; inset:0; display:none; place-items:center; color:#fecaca;">
|
||||||
|
{{ __('city.error') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Click-to-start overlay (pointer lock gate) --}}
|
||||||
|
<div id="start-gate"
|
||||||
|
style="position:absolute; inset:0; display:grid; place-items:center; background:rgba(11,18,32,.75);
|
||||||
|
cursor:pointer; z-index:10; text-align:center;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:3rem; margin-bottom:.5rem;">🎮</div>
|
||||||
|
<p style="color:#fff; font-size:1.1rem; margin:0 0 .3rem;">{{ __('city.hint_fps') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- HUD bottom-left: POSIÇÃO panel (like example images) --}}
|
||||||
|
<div id="hud-pos"
|
||||||
|
style="position:absolute; left:.6rem; bottom:.6rem; z-index:5; display:none;
|
||||||
|
background:rgba(10,15,28,.88); border:1px solid rgba(148,163,184,.25); border-radius:.4rem;
|
||||||
|
padding:.5rem .7rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.72rem;
|
||||||
|
color:#94a3b8; line-height:1.55; min-width:170px; pointer-events:none;">
|
||||||
|
<div style="color:#e2e8f0; font-weight:700; letter-spacing:.06em; font-size:.65rem; margin-bottom:.2rem;">{{ __('city.position') }}</div>
|
||||||
|
<div id="pos-xyz">x:0.0 y:1.7 z:0.0</div>
|
||||||
|
<div id="pos-ground">—</div>
|
||||||
|
<div id="pos-tris">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- HUD bottom-right: selected / hint --}}
|
||||||
|
<div id="hud-hint"
|
||||||
|
style="position:absolute; right:.6rem; bottom:.6rem; z-index:5; display:flex; gap:.5rem;
|
||||||
|
pointer-events:none; flex-wrap:wrap; justify-content:flex-end;">
|
||||||
|
<span id="selected" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
|
||||||
|
padding:.35rem .6rem; font-size:.8rem; color:#7dd3fc; display:none;"></span>
|
||||||
|
<span id="hint" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
|
||||||
|
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Chat panel (bottom-right, like example images) --}}
|
||||||
|
<div id="chat-panel"
|
||||||
|
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
|
||||||
|
background:rgba(15,23,42,.96); border:1px solid var(--border); border-radius:.6rem;
|
||||||
|
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
|
||||||
|
<div id="chat-header"
|
||||||
|
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
|
||||||
|
padding:.55rem .7rem; background:rgba(30,41,59,.9); border-bottom:1px solid var(--border);">
|
||||||
|
<div style="display:flex; align-items:center; gap:.5rem; min-width:0;">
|
||||||
|
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<div id="chat-name" style="font-weight:700; font-size:.85rem; color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||||||
|
<div id="chat-status" style="font-size:.7rem; color:var(--muted);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:.3rem;">
|
||||||
|
<span id="chat-time" style="font-size:.65rem; color:var(--muted); align-self:center;"></span>
|
||||||
|
<button type="button" id="chat-close"
|
||||||
|
style="background:transparent; border:none; color:var(--muted); cursor:pointer; font-size:1rem; padding:0 .2rem;"
|
||||||
|
aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages"
|
||||||
|
style="padding:.6rem .7rem; overflow-y:auto; max-height:260px; display:flex; flex-direction:column;
|
||||||
|
gap:.5rem; font-size:.78rem; line-height:1.5;"></div>
|
||||||
|
<form id="chat-form"
|
||||||
|
style="display:flex; gap:.4rem; padding:.5rem .6rem; border-top:1px solid var(--border); background:rgba(11,18,32,.7);">
|
||||||
|
<input id="chat-input" type="text" maxlength="500" required
|
||||||
|
placeholder="{{ __('city.chat_placeholder') }}"
|
||||||
|
style="flex:1; min-width:0; padding:.45rem .6rem; border-radius:.4rem; border:1px solid var(--border);
|
||||||
|
background:#0b1220; color:var(--text); font-size:.8rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" style="padding:.4rem .7rem; font-size:.75rem;">
|
||||||
|
{{ __('city.chat_send') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
#scene canvas { display: block; touch-action: none; }
|
||||||
|
#chat-messages::-webkit-scrollbar { width: 6px; }
|
||||||
|
#chat-messages::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||||
|
.chat-bubble {
|
||||||
|
padding:.4rem .6rem; border-radius:.5rem; max-width:92%; word-break:break-word;
|
||||||
|
}
|
||||||
|
.chat-bubble.user {
|
||||||
|
background:rgba(14,165,233,.22); border:1px solid rgba(14,165,233,.4); color:#bae6fd; align-self:flex-end;
|
||||||
|
}
|
||||||
|
.chat-bubble.npc {
|
||||||
|
background:rgba(51,65,85,.55); border:1px solid rgba(148,163,184,.2); color:#e2e8f0; align-self:flex-start;
|
||||||
|
}
|
||||||
|
.chat-bubble.meta {
|
||||||
|
background:transparent; border:none; color:var(--muted); font-size:.7rem; align-self:center;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#scene { height: 62vh !important; }
|
||||||
|
#hint { display: none; }
|
||||||
|
#chat-panel { width: calc(100% - 1.2rem); right: .6rem; left: .6rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from '/vendor/three/three.module.min.js';
|
||||||
|
|
||||||
|
const host = document.getElementById('scene');
|
||||||
|
const loadingEl = document.getElementById('loading');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const startGate = document.getElementById('start-gate');
|
||||||
|
const statsEl = document.getElementById('stats');
|
||||||
|
const selectedEl = document.getElementById('selected');
|
||||||
|
const reloadBtn = document.getElementById('reload');
|
||||||
|
const posPanel = document.getElementById('hud-pos');
|
||||||
|
const posXYZ = document.getElementById('pos-xyz');
|
||||||
|
const posGround = document.getElementById('pos-ground');
|
||||||
|
const posTris = document.getElementById('pos-tris');
|
||||||
|
const dataUrl = @json(route('city.data'));
|
||||||
|
const csrf = @json(csrf_token());
|
||||||
|
const i18n = {
|
||||||
|
stores: @json(__('city.stores_count')),
|
||||||
|
agents: @json(__('city.agents_count')),
|
||||||
|
open: @json(__('city.open')),
|
||||||
|
closed: @json(__('city.closed')),
|
||||||
|
selected: @json(__('city.selected')),
|
||||||
|
position: @json(__('city.position')),
|
||||||
|
onGround: @json(__('city.on_ground')),
|
||||||
|
tris: @json(__('city.triangles')),
|
||||||
|
chatThinking: @json(__('city.chat_thinking')),
|
||||||
|
chatResponding: @json(__('city.chat_responding')),
|
||||||
|
chatWith: @json(__('city.chat_with')),
|
||||||
|
chatSend: @json(__('city.chat_send')),
|
||||||
|
chatError: @json(__('city.chat_error')),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Scene setup — daytime sky, fog, lighting like the example images
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x87ceeb); // sky blue
|
||||||
|
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
|
||||||
|
camera.position.set(0, 1.7, 0); // eye height 1.7m
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.05;
|
||||||
|
host.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
scene.add(new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55));
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
|
||||||
|
sun.position.set(80, 140, 60);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
|
sun.shadow.camera.left = -200;
|
||||||
|
sun.shadow.camera.right = 200;
|
||||||
|
sun.shadow.camera.top = 200;
|
||||||
|
sun.shadow.camera.bottom = -200;
|
||||||
|
sun.shadow.camera.far = 500;
|
||||||
|
scene.add(sun);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Texture helpers (canvas-generated — no external assets required)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = size; c.height = size;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
draw(ctx, size);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
tex.repeat.set(repeatX, repeatY);
|
||||||
|
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asphalt with subtle noise + double white center lines
|
||||||
|
const asphaltTex = canvasTexture(512, (ctx, s) => {
|
||||||
|
ctx.fillStyle = '#1a1a1e';
|
||||||
|
ctx.fillRect(0, 0, s, s);
|
||||||
|
// noise
|
||||||
|
for (let i = 0; i < 9000; i++) {
|
||||||
|
const v = 20 + Math.random() * 30;
|
||||||
|
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
|
||||||
|
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
|
||||||
|
}
|
||||||
|
// double white lines down the center
|
||||||
|
ctx.fillStyle = '#e8e8e8';
|
||||||
|
ctx.fillRect(s * 0.44, 0, 4, s);
|
||||||
|
ctx.fillRect(s * 0.54, 0, 4, s);
|
||||||
|
// dashed side lines
|
||||||
|
ctx.fillStyle = '#d0d0d0';
|
||||||
|
for (let y = 0; y < s; y += 48) {
|
||||||
|
ctx.fillRect(s * 0.08, y, 4, 26);
|
||||||
|
ctx.fillRect(s * 0.90, y, 4, 26);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidewalk — light grey tiles
|
||||||
|
const sidewalkTex = canvasTexture(256, (ctx, s) => {
|
||||||
|
ctx.fillStyle = '#b8bcc4';
|
||||||
|
ctx.fillRect(0, 0, s, s);
|
||||||
|
const tile = 64;
|
||||||
|
ctx.strokeStyle = '#9aa0a8';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i <= s; i += tile) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
|
||||||
|
}
|
||||||
|
// subtle stains
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}, 4, 1);
|
||||||
|
|
||||||
|
// Building facade — mustard/yellow with dark window grid
|
||||||
|
function facadeTexture(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
|
||||||
|
const w = 512, h = 512;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = w; c.height = h;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.fillStyle = baseColor;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const floorH = h / floorCount;
|
||||||
|
const winW = w / (windowsPerFloor * 2 + 1);
|
||||||
|
const winH = floorH * 0.5;
|
||||||
|
|
||||||
|
for (let f = 0; f < floorCount; f++) {
|
||||||
|
// dark horizontal band between floors
|
||||||
|
ctx.fillStyle = 'rgba(30,30,30,0.85)';
|
||||||
|
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
|
||||||
|
|
||||||
|
for (let wi = 0; wi < windowsPerFloor; wi++) {
|
||||||
|
const x = (wi * 2 + 1) * winW * 0.5;
|
||||||
|
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
|
||||||
|
const wy = f * floorH + floorH * 0.18;
|
||||||
|
const ww = (w / windowsPerFloor) * 0.5;
|
||||||
|
const wh = winH;
|
||||||
|
|
||||||
|
// window frame (dark)
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
|
||||||
|
|
||||||
|
// glass
|
||||||
|
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
|
||||||
|
ctx.fillRect(wx, wy, ww, wh);
|
||||||
|
|
||||||
|
// reflection
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||||
|
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vertical pilasters
|
||||||
|
ctx.fillStyle = 'rgba(40,40,40,0.7)';
|
||||||
|
ctx.fillRect(0, 0, 6, h);
|
||||||
|
ctx.fillRect(w - 6, 0, 6, h);
|
||||||
|
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Door texture for ground floor
|
||||||
|
function doorTexture() {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 128; c.height = 256;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, 128, 256);
|
||||||
|
ctx.fillStyle = '#3a2f1a';
|
||||||
|
ctx.fillRect(8, 8, 112, 240);
|
||||||
|
ctx.fillStyle = '#c9a84c';
|
||||||
|
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// City layout from server payload
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
let cityData = null;
|
||||||
|
const colliders = []; // AABB boxes {minX,maxX,minY,maxY,minZ,maxZ}
|
||||||
|
const worldGroup = new THREE.Group();
|
||||||
|
scene.add(worldGroup);
|
||||||
|
const storeMeshes = [];
|
||||||
|
const agentMeshes = [];
|
||||||
|
|
||||||
|
function clearGroup(g) {
|
||||||
|
while (g.children.length) {
|
||||||
|
const c = g.children.pop();
|
||||||
|
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.map?.dispose?.(); o.material?.dispose?.(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCollider(cx, cy, cz, w, h, d) {
|
||||||
|
colliders.push({
|
||||||
|
minX: cx - w / 2, maxX: cx + w / 2,
|
||||||
|
minY: cy, maxY: cy + h,
|
||||||
|
minZ: cz - d / 2, maxZ: cz + d / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCity(data) {
|
||||||
|
clearGroup(worldGroup);
|
||||||
|
colliders.length = 0;
|
||||||
|
storeMeshes.length = 0;
|
||||||
|
agentMeshes.length = 0;
|
||||||
|
|
||||||
|
const layout = data.layout || { roads: [], blocks: [] };
|
||||||
|
const stores = data.stores || [];
|
||||||
|
const agents = data.agents || [];
|
||||||
|
|
||||||
|
// ---- Ground ----
|
||||||
|
const ground = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(2000, 2000),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
|
||||||
|
);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.position.y = -0.05;
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
worldGroup.add(ground);
|
||||||
|
|
||||||
|
// ---- Roads (asphalt strips with center lines) ----
|
||||||
|
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
|
||||||
|
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
|
||||||
|
walkMat.map.repeat.set(4, 1);
|
||||||
|
|
||||||
|
layout.roads.forEach(r => {
|
||||||
|
const [x1,, z1] = r.from;
|
||||||
|
const [x2,, z2] = r.to;
|
||||||
|
const w = r.width;
|
||||||
|
const len = Math.hypot(x2 - x1, z2 - z1);
|
||||||
|
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
|
||||||
|
|
||||||
|
// Asphalt
|
||||||
|
const geo = r.axis === 'z'
|
||||||
|
? new THREE.PlaneGeometry(w, len)
|
||||||
|
: new THREE.PlaneGeometry(len, w);
|
||||||
|
const mesh = new THREE.Mesh(geo, roadMat);
|
||||||
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
|
mesh.position.set(mx, 0.01, mz);
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
worldGroup.add(mesh);
|
||||||
|
|
||||||
|
// Sidewalks on both sides (2m each)
|
||||||
|
const swWidth = 2;
|
||||||
|
const offset = w / 2 + swWidth / 2;
|
||||||
|
[-1, 1].forEach(side => {
|
||||||
|
const sg = r.axis === 'z'
|
||||||
|
? new THREE.PlaneGeometry(swWidth, len)
|
||||||
|
: new THREE.PlaneGeometry(len, swWidth);
|
||||||
|
const sm = new THREE.Mesh(sg, walkMat);
|
||||||
|
sm.rotation.x = -Math.PI / 2;
|
||||||
|
if (r.axis === 'z') {
|
||||||
|
sm.position.set(mx + side * offset, 0.12, mz);
|
||||||
|
} else {
|
||||||
|
sm.position.set(mx, 0.12, mz + side * offset);
|
||||||
|
}
|
||||||
|
sm.receiveShadow = true;
|
||||||
|
worldGroup.add(sm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Sidewalk curb edges (thin boxes for depth) ----
|
||||||
|
layout.roads.forEach(r => {
|
||||||
|
const [x1,, z1] = r.from;
|
||||||
|
const [x2,, z2] = r.to;
|
||||||
|
const w = r.width;
|
||||||
|
const len = Math.hypot(x2 - x1, z2 - z1);
|
||||||
|
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
|
||||||
|
const offset = w / 2 + 0.1;
|
||||||
|
[-1, 1].forEach(side => {
|
||||||
|
const curbGeo = r.axis === 'z'
|
||||||
|
? new THREE.BoxGeometry(0.2, 0.14, len)
|
||||||
|
: new THREE.BoxGeometry(len, 0.14, 0.2);
|
||||||
|
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
|
||||||
|
if (r.axis === 'z') {
|
||||||
|
curb.position.set(mx + side * offset, 0.07, mz);
|
||||||
|
} else {
|
||||||
|
curb.position.set(mx, 0.07, mz + side * offset);
|
||||||
|
}
|
||||||
|
worldGroup.add(curb);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Street lamps every ~30 units along roads ----
|
||||||
|
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
|
||||||
|
const lampHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0x444422, emissiveIntensity: 0.4 });
|
||||||
|
layout.roads.forEach(r => {
|
||||||
|
const [x1,, z1] = r.from;
|
||||||
|
const [x2,, z2] = r.to;
|
||||||
|
const len = Math.hypot(x2 - x1, z2 - z1);
|
||||||
|
const w = r.width;
|
||||||
|
const spacing = 30;
|
||||||
|
const count = Math.max(1, Math.floor(len / spacing));
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const t = i / count;
|
||||||
|
const lx = x1 + (x2 - x1) * t;
|
||||||
|
const lz = z1 + (z2 - z1) * t;
|
||||||
|
// offset to sidewalk edge
|
||||||
|
const ox = r.axis === 'z' ? w / 2 + 1.4 : 0;
|
||||||
|
const oz = r.axis === 'x' ? w / 2 + 1.4 : 0;
|
||||||
|
const side = (i % 2 === 0) ? 1 : -1;
|
||||||
|
|
||||||
|
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
|
||||||
|
pole.position.set(lx + ox * side, 2.25, lz + oz * side);
|
||||||
|
pole.castShadow = true;
|
||||||
|
worldGroup.add(pole);
|
||||||
|
|
||||||
|
// arm
|
||||||
|
const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat);
|
||||||
|
arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7);
|
||||||
|
worldGroup.add(arm);
|
||||||
|
|
||||||
|
// head
|
||||||
|
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.5), lampHeadMat);
|
||||||
|
head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3);
|
||||||
|
worldGroup.add(head);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Stores as buildings with facades ----
|
||||||
|
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
|
||||||
|
stores.forEach((s, idx) => {
|
||||||
|
const [x, y, z] = s.position;
|
||||||
|
const [w, h, d] = s.size;
|
||||||
|
const color = palette[idx % palette.length];
|
||||||
|
|
||||||
|
const floors = Math.max(2, Math.round(h / 3));
|
||||||
|
const facadeTex = facadeTexture(color, floors, Math.max(2, Math.round(w / 3)));
|
||||||
|
|
||||||
|
const geo = new THREE.BoxGeometry(w, h, d);
|
||||||
|
const mats = [
|
||||||
|
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +x
|
||||||
|
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -x
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9 }), // +y roof
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9 }), // -y
|
||||||
|
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +z
|
||||||
|
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -z
|
||||||
|
];
|
||||||
|
const mesh = new THREE.Mesh(geo, mats);
|
||||||
|
mesh.position.set(x, y + h / 2, z);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData = { store: s };
|
||||||
|
worldGroup.add(mesh);
|
||||||
|
storeMeshes.push(mesh);
|
||||||
|
addCollider(x, y, z, w, h, d);
|
||||||
|
|
||||||
|
// Roof cap
|
||||||
|
const roof = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 })
|
||||||
|
);
|
||||||
|
roof.position.set(x, y + h + 0.17, z);
|
||||||
|
roof.castShadow = true;
|
||||||
|
worldGroup.add(roof);
|
||||||
|
|
||||||
|
// Door on ground floor facing +Z
|
||||||
|
const door = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(1.4, 2.4, 0.1),
|
||||||
|
new THREE.MeshStandardMaterial({ map: doorTexture(), roughness: 0.7 })
|
||||||
|
);
|
||||||
|
door.position.set(x, y + 1.2, z + d / 2 + 0.05);
|
||||||
|
worldGroup.add(door);
|
||||||
|
|
||||||
|
// Ground marker ring (kept for selection feedback)
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(Math.max(w, d) / 2 + 0.6, Math.max(w, d) / 2 + 1.1, 32),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x0ea5e9, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.set(x, 0.15, z);
|
||||||
|
worldGroup.add(ring);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Procedural filler buildings in empty blocks ----
|
||||||
|
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
|
||||||
|
(layout.blocks || []).forEach((b, bi) => {
|
||||||
|
const cx = (b.min[0] + b.max[0]) / 2;
|
||||||
|
const cz = (b.min[2] + b.max[2]) / 2;
|
||||||
|
const nearStore = occupied.some(o => Math.abs(o.x - cx) < 25 && Math.abs(o.z - cz) < 25);
|
||||||
|
if (nearStore) return;
|
||||||
|
|
||||||
|
const bw = 12 + (bi * 7) % 14;
|
||||||
|
const bd = 12 + (bi * 11) % 14;
|
||||||
|
const bh = 6 + (bi * 13) % 20;
|
||||||
|
const color = palette[(bi + 2) % palette.length];
|
||||||
|
const floors = Math.max(2, Math.round(bh / 3));
|
||||||
|
const tex = facadeTexture(color, floors, Math.max(2, Math.round(bw / 3)));
|
||||||
|
|
||||||
|
const geo = new THREE.BoxGeometry(bw, bh, bd);
|
||||||
|
const mats = Array.from({ length: 6 }, () => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 }));
|
||||||
|
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 }); // roof
|
||||||
|
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
|
||||||
|
|
||||||
|
const m = new THREE.Mesh(geo, mats);
|
||||||
|
m.position.set(cx, bh / 2, cz);
|
||||||
|
m.castShadow = true;
|
||||||
|
m.receiveShadow = true;
|
||||||
|
worldGroup.add(m);
|
||||||
|
addCollider(cx, 0, cz, bw, bh, bd);
|
||||||
|
|
||||||
|
// small roof
|
||||||
|
const roof = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
|
||||||
|
);
|
||||||
|
roof.position.set(cx, bh + 0.15, cz);
|
||||||
|
worldGroup.add(roof);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Agents as procedural humanoid characters ----
|
||||||
|
agents.forEach(a => {
|
||||||
|
const [x, y, z] = a.position;
|
||||||
|
const char = buildHumanoid(a);
|
||||||
|
char.position.set(x, y, z);
|
||||||
|
char.rotation.y = a.rotation_y || 0;
|
||||||
|
worldGroup.add(char);
|
||||||
|
agentMeshes.push(char);
|
||||||
|
});
|
||||||
|
|
||||||
|
statsEl.textContent = `${stores.length} ${i18n.stores} · ${agents.length} ${i18n.agents}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Humanoid character builder (procedural, no external GLTF required)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function buildHumanoid(agent) {
|
||||||
|
const g = new THREE.Group();
|
||||||
|
const type = agent.type || 'pedestrian';
|
||||||
|
|
||||||
|
// Per-type palette
|
||||||
|
const configs = {
|
||||||
|
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
|
||||||
|
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
|
||||||
|
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
|
||||||
|
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
|
||||||
|
};
|
||||||
|
const cfg = configs[type] || configs.pedestrian;
|
||||||
|
const s = cfg.scale;
|
||||||
|
|
||||||
|
const skinMat = new THREE.MeshStandardMaterial({ color: cfg.skin, roughness: 0.7 });
|
||||||
|
const hairMat = new THREE.MeshStandardMaterial({ color: cfg.hair, roughness: 0.6 });
|
||||||
|
const shirtMat = new THREE.MeshStandardMaterial({ color: cfg.shirt, roughness: 0.8 });
|
||||||
|
const pantsMat = new THREE.MeshStandardMaterial({ color: cfg.pants, roughness: 0.85 });
|
||||||
|
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
|
||||||
|
|
||||||
|
// Head
|
||||||
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
|
||||||
|
head.position.y = 1.62 * s;
|
||||||
|
head.castShadow = true;
|
||||||
|
g.add(head);
|
||||||
|
|
||||||
|
// Hair cap
|
||||||
|
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
|
||||||
|
hair.position.y = 1.65 * s;
|
||||||
|
hair.castShadow = true;
|
||||||
|
g.add(hair);
|
||||||
|
|
||||||
|
// Torso
|
||||||
|
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
|
||||||
|
torso.position.y = 1.12 * s;
|
||||||
|
torso.castShadow = true;
|
||||||
|
g.add(torso);
|
||||||
|
|
||||||
|
// Arms
|
||||||
|
[-1, 1].forEach(side => {
|
||||||
|
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
|
||||||
|
arm.position.set(side * 0.32 * s, 1.1 * s, 0);
|
||||||
|
arm.rotation.z = side * 0.18;
|
||||||
|
arm.castShadow = true;
|
||||||
|
g.add(arm);
|
||||||
|
|
||||||
|
// Hand
|
||||||
|
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
|
||||||
|
hand.position.set(side * 0.34 * s, 0.82 * s, 0);
|
||||||
|
g.add(hand);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
[-1, 1].forEach(side => {
|
||||||
|
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
|
||||||
|
leg.position.set(side * 0.13 * s, 0.42 * s, 0);
|
||||||
|
leg.castShadow = true;
|
||||||
|
g.add(leg);
|
||||||
|
|
||||||
|
// Shoe
|
||||||
|
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
|
||||||
|
shoe.position.set(side * 0.13 * s, 0.04 * s, 0.05 * s);
|
||||||
|
shoe.castShadow = true;
|
||||||
|
g.add(shoe);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Name label (billboard)
|
||||||
|
const label = makeLabel(agent.name, agent.status);
|
||||||
|
label.position.y = 2.15 * s;
|
||||||
|
g.add(label);
|
||||||
|
|
||||||
|
g.userData = { agent, label };
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLabel(name, status) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 256; c.height = 64;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.fillStyle = 'rgba(10,15,28,0.85)';
|
||||||
|
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#e2e8f0';
|
||||||
|
ctx.font = 'bold 22px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(name, 128, 30);
|
||||||
|
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
|
||||||
|
ctx.fillStyle = dotColor;
|
||||||
|
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#94a3b8';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(status || 'active', 56, 53);
|
||||||
|
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
|
||||||
|
sprite.scale.set(3.2, 0.8, 1);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// CameraController — first person WASD + pointer lock + AABB collision
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const player = {
|
||||||
|
pos: new THREE.Vector3(0, 1.7, 25),
|
||||||
|
vel: new THREE.Vector3(),
|
||||||
|
yaw: 0, // horizontal rotation
|
||||||
|
pitch: 0, // vertical look
|
||||||
|
speed: 6,
|
||||||
|
radius: 0.35,
|
||||||
|
eyeHeight: 1.7,
|
||||||
|
onGround: true,
|
||||||
|
};
|
||||||
|
const keys = {};
|
||||||
|
let locked = false;
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('click', () => {
|
||||||
|
if (!locked) renderer.domElement.requestPointerLock();
|
||||||
|
});
|
||||||
|
startGate.addEventListener('click', () => {
|
||||||
|
renderer.domElement.requestPointerLock();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('pointerlockchange', () => {
|
||||||
|
locked = document.pointerLockElement === renderer.domElement;
|
||||||
|
startGate.style.display = locked ? 'none' : 'grid';
|
||||||
|
posPanel.style.display = locked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!locked) return;
|
||||||
|
player.yaw -= e.movementX * 0.0022;
|
||||||
|
player.pitch -= e.movementY * 0.0022;
|
||||||
|
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => { keys[e.code] = true; });
|
||||||
|
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
|
||||||
|
|
||||||
|
function playerAABB(px, py, pz) {
|
||||||
|
const r = player.radius;
|
||||||
|
return {
|
||||||
|
minX: px - r, maxX: px + r,
|
||||||
|
minY: py - 1.5, maxY: py + 0.3,
|
||||||
|
minZ: pz - r, maxZ: pz + r,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a, b) {
|
||||||
|
return a.minX < b.maxX && a.maxX > b.minX &&
|
||||||
|
a.minZ < b.maxZ && a.maxZ > b.minZ &&
|
||||||
|
a.minY < b.maxY && a.maxY > b.minY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCollision(px, py, pz) {
|
||||||
|
const box = playerAABB(px, py, pz);
|
||||||
|
let hit = false;
|
||||||
|
for (const c of colliders) {
|
||||||
|
if (overlaps(box, c)) { hit = true; break; }
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayer(dt) {
|
||||||
|
if (!locked) return;
|
||||||
|
|
||||||
|
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
||||||
|
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
||||||
|
const wish = new THREE.Vector3();
|
||||||
|
|
||||||
|
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
||||||
|
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||||||
|
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
||||||
|
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
|
||||||
|
|
||||||
|
if (wish.lengthSq() > 0) wish.normalize();
|
||||||
|
|
||||||
|
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
|
||||||
|
const step = wish.multiplyScalar(speed * dt);
|
||||||
|
|
||||||
|
// Axis-separated collision resolution
|
||||||
|
const tryX = player.pos.x + step.x;
|
||||||
|
if (!resolveCollision(tryX, player.pos.y, player.pos.z)) player.pos.x = tryX;
|
||||||
|
|
||||||
|
const tryZ = player.pos.z + step.z;
|
||||||
|
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
|
||||||
|
|
||||||
|
// Simple gravity + ground
|
||||||
|
player.vel.y -= 18 * dt;
|
||||||
|
player.pos.y += player.vel.y * dt;
|
||||||
|
if (player.pos.y <= player.eyeHeight) {
|
||||||
|
player.pos.y = player.eyeHeight;
|
||||||
|
player.vel.y = 0;
|
||||||
|
player.onGround = true;
|
||||||
|
}
|
||||||
|
if (keys['Space'] && player.onGround) {
|
||||||
|
player.vel.y = 6;
|
||||||
|
player.onGround = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// World bounds
|
||||||
|
player.pos.x = Math.max(-400, Math.min(400, player.pos.x));
|
||||||
|
player.pos.z = Math.max(-400, Math.min(400, player.pos.z));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCameraFromPlayer() {
|
||||||
|
camera.position.copy(player.pos);
|
||||||
|
const dir = new THREE.Vector3(
|
||||||
|
-Math.sin(player.yaw) * Math.cos(player.pitch),
|
||||||
|
Math.sin(player.pitch),
|
||||||
|
-Math.cos(player.yaw) * Math.cos(player.pitch)
|
||||||
|
);
|
||||||
|
camera.lookAt(camera.position.clone().add(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Click-to-interact: select store or open chat with NPC
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const ray = new THREE.Raycaster();
|
||||||
|
const ndc = new THREE.Vector2();
|
||||||
|
let pointerDown = null;
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||||||
|
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('pointerup', (e) => {
|
||||||
|
if (!pointerDown) return;
|
||||||
|
const dx = Math.abs(e.clientX - pointerDown.x);
|
||||||
|
const dy = Math.abs(e.clientY - pointerDown.y);
|
||||||
|
const dt = Date.now() - pointerDown.t;
|
||||||
|
pointerDown = null;
|
||||||
|
if (dx > 6 || dy > 6 || dt > 400) return; // drag, not click
|
||||||
|
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
ray.setFromCamera(ndc, camera);
|
||||||
|
|
||||||
|
// Check agents first
|
||||||
|
const agentHits = ray.intersectObjects(agentMeshes, true);
|
||||||
|
if (agentHits.length) {
|
||||||
|
let obj = agentHits[0].object;
|
||||||
|
while (obj && !obj.userData?.agent) obj = obj.parent;
|
||||||
|
if (obj?.userData?.agent) {
|
||||||
|
openChat(obj.userData.agent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then stores
|
||||||
|
const storeHits = ray.intersectObjects(storeMeshes, false);
|
||||||
|
if (storeHits.length) {
|
||||||
|
const s = storeHits[0].object.userData.store;
|
||||||
|
selectedEl.style.display = 'inline-block';
|
||||||
|
selectedEl.textContent = `${i18n.selected}: ${s.name} · ${s.is_open ? i18n.open : i18n.closed}`;
|
||||||
|
} else {
|
||||||
|
selectedEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// ChatPanel — SSE stream + POST message
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const chatPanel = document.getElementById('chat-panel');
|
||||||
|
const chatHeader = document.getElementById('chat-header');
|
||||||
|
const chatDot = document.getElementById('chat-dot');
|
||||||
|
const chatName = document.getElementById('chat-name');
|
||||||
|
const chatStatus = document.getElementById('chat-status');
|
||||||
|
const chatTime = document.getElementById('chat-time');
|
||||||
|
const chatClose = document.getElementById('chat-close');
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
|
||||||
|
let currentAgent = null;
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
function addBubble(text, who) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `chat-bubble ${who}`;
|
||||||
|
div.textContent = text;
|
||||||
|
chatMessages.appendChild(div);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(status) {
|
||||||
|
if (!currentAgent) return;
|
||||||
|
currentAgent.status = status;
|
||||||
|
const map = { thinking: ['#f59e0b', i18n.chatThinking], responding: ['#f59e0b', i18n.chatResponding], active: ['#22c55e', 'online'], error: ['#ef4444', 'error'] };
|
||||||
|
const [color, label] = map[status] || map.active;
|
||||||
|
chatDot.style.background = color;
|
||||||
|
chatStatus.textContent = label;
|
||||||
|
// update sprite label
|
||||||
|
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
|
||||||
|
if (mesh) {
|
||||||
|
const old = mesh.userData.label;
|
||||||
|
if (old) {
|
||||||
|
mesh.remove(old);
|
||||||
|
old.material.map?.dispose();
|
||||||
|
}
|
||||||
|
const l = makeLabel(currentAgent.name, status);
|
||||||
|
l.position.y = old ? old.position.y : 2.15;
|
||||||
|
mesh.add(l);
|
||||||
|
mesh.userData.label = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChat(agent) {
|
||||||
|
currentAgent = agent;
|
||||||
|
chatPanel.style.display = 'flex';
|
||||||
|
chatName.textContent = agent.name;
|
||||||
|
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
chatMessages.innerHTML = '';
|
||||||
|
|
||||||
|
// Replay history
|
||||||
|
const history = agent.last_decision || [];
|
||||||
|
history.forEach(m => {
|
||||||
|
if (m.role === 'user') addBubble(m.content, 'user');
|
||||||
|
else if (m.role === 'assistant') addBubble(m.content, 'npc');
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus(agent.status || 'active');
|
||||||
|
|
||||||
|
// Open SSE stream
|
||||||
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||||
|
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
|
||||||
|
eventSource.addEventListener('message', (e) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.data);
|
||||||
|
if (payload.type === 'thinking') {
|
||||||
|
setStatus('thinking');
|
||||||
|
addBubble(i18n.chatThinking, 'meta');
|
||||||
|
} else if (payload.type === 'reply') {
|
||||||
|
setStatus('active');
|
||||||
|
addBubble(payload.reply, 'npc');
|
||||||
|
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} else if (payload.type === 'error') {
|
||||||
|
setStatus('error');
|
||||||
|
addBubble(payload.reply || i18n.chatError, 'meta');
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore parse errors */ }
|
||||||
|
});
|
||||||
|
eventSource.onerror = () => { /* auto-retry handled by EventSource */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChat() {
|
||||||
|
chatPanel.style.display = 'none';
|
||||||
|
currentAgent = null;
|
||||||
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
chatClose.addEventListener('click', closeChat);
|
||||||
|
|
||||||
|
chatForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = chatInput.value.trim();
|
||||||
|
if (!msg || !currentAgent) return;
|
||||||
|
chatInput.value = '';
|
||||||
|
addBubble(msg, 'user');
|
||||||
|
setStatus('thinking');
|
||||||
|
try {
|
||||||
|
await fetch(`/city/chat/${currentAgent.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: msg }),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
addBubble(i18n.chatError, 'meta');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// HUD position update
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function updateHUD() {
|
||||||
|
const g = player.pos.y <= player.eyeHeight + 0.01;
|
||||||
|
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
|
||||||
|
posGround.textContent = g
|
||||||
|
? `${i18n.onGround} · ${player.pos.y.toFixed(1)}m`
|
||||||
|
: `airborne · ${player.pos.y.toFixed(1)}m`;
|
||||||
|
posTris.textContent = `${colliders.length} ${i18n.tris}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Resize + animation loop
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function resize() {
|
||||||
|
const w = host.clientWidth, h = host.clientHeight;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
}
|
||||||
|
new ResizeObserver(resize).observe(host);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
let lastT = performance.now();
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = Math.min(0.05, (now - lastT) / 1000);
|
||||||
|
lastT = now;
|
||||||
|
|
||||||
|
updatePlayer(dt);
|
||||||
|
updateCameraFromPlayer();
|
||||||
|
updateHUD();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Data loading
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async function loadCity() {
|
||||||
|
loadingEl.style.display = 'grid';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
cityData = await res.json();
|
||||||
|
buildCity(cityData);
|
||||||
|
// Seed agent last_decision from server
|
||||||
|
(cityData.agents || []).forEach(a => {
|
||||||
|
// placeholder — history is fetched lazily on openChat via SSE
|
||||||
|
});
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
errorEl.style.display = 'grid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadBtn.addEventListener('click', loadCity);
|
||||||
|
loadCity();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||