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
This commit is contained in:
2026-10-17 20:46:15 -03:00
commit 79d12fa2f4
128 changed files with 17053 additions and 0 deletions
+376
View File
@@ -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