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
@@ -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
+62
View File
@@ -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
+80
View File
@@ -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
+995
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.app')
@section('title', __('Dashboard'))
@section('content')
<div style="max-width: 960px; margin: 0 auto;">
<div class="card" style="margin-bottom:1.25rem;">
<h1 style="margin-top:0; color:#fff;">👋 Olá, {{ $user->name }}</h1>
<p style="color:var(--muted); margin-bottom:.5rem;">
Você está autenticado como
<span class="badge {{ $isAdmin ? 'badge-admin' : 'badge-user' }}">{{ $isAdmin ? 'Administrador' : 'Usuário' }}</span>
— idioma: {{ $user->locale }}.
</p>
<p style="color:var(--muted); font-size:.9rem;">
Último acesso: {{ $user->last_login_at?->format('d/m/Y H:i:s') ?? '—' }}
</p>
</div>
<div class="grid-3">
<div class="card">
<h3 style="margin-top:0; color:#fff;">🏙️ {{ __('nav.city') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">
<a href="{{ route('city.viewer') }}">{{ __('dashboard.open_viewer') }}</a><br>
{{ __('dashboard.viewer_desc') }}
</p>
</div>
@if ($isAdmin)
<div class="card" style="border-color:rgba(99,102,241,.5);">
<h3 style="margin-top:0; color:#fff;">⚙️ {{ __('nav.admin') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">
<a href="{{ route('admin.stores.index') }}">{{ __('admin.stores.manage') }}</a><br>
{{ __('admin.stores.admin_desc') }}
</p>
</div>
@endif
<div class="card">
<h3 style="margin-top:0; color:#fff;">🏪 {{ __('dashboard.stores_title') }}</h3>
<p style="font-size:2rem; margin:.2rem 0; color:var(--brand);">{{ $storeCount }}</p>
<p style="color:var(--muted); font-size:.9rem;">{{ __('dashboard.stores_desc') }}</p>
</div>
<div class="card">
<h3 style="margin-top:0; color:#fff;">📜 {{ __('dashboard.legal_title') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">
<a href="{{ route('terms.history') }}">{{ __('dashboard.consent_history') }}</a><br>
<a href="{{ route('legal.show', 'terms') }}">{{ __('legal.terms_label') }}</a> ·
<a href="{{ route('legal.show', 'privacy') }}">{{ __('legal.privacy_label') }}</a> ·
<a href="{{ route('legal.show', 'cookies') }}">{{ __('legal.cookies_label') }}</a>
</p>
</div>
</div>
</div>
@endsection
+197
View File
@@ -0,0 +1,197 @@
{{-- Shared application layout for all City10 web pages. --}}
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', config('app.name', 'City10'))</title>
<style>
:root {
--brand: #0ea5e9;
--brand-dark: #0284c7;
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--border: #334155;
--danger: #ef4444;
--ok: #22c55e;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Instrument Sans", sans-serif;
background: linear-gradient(160deg, #0f172a 0%, #0c1a2e 100%);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
a { color: var(--brand); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 100%; max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
header.site {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 0; border-bottom: 1px solid var(--border); gap: 1rem; flex-wrap: wrap;
}
.logo { display: flex; align-items: center; gap: .6rem; font-weight: 700; font-size: 1.15rem; color: #fff; }
.logo .mark {
width: 34px; height: 34px; border-radius: 9px;
background: linear-gradient(135deg, var(--brand), #6366f1);
display: grid; place-items: center; font-size: .85rem; color: #fff; font-weight: 800;
}
nav.site { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
nav.site a, .btn {
padding: .5rem .9rem; border-radius: .55rem; font-size: .9rem; border: 1px solid var(--border);
color: var(--text); background: transparent; cursor: pointer; transition: all .15s ease;
}
nav.site a:hover, .btn:hover { background: var(--surface-2); text-decoration: none; }
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600; }
.btn-primary:hover { background: var(--brand-dark); }
.lang-switch { display: flex; align-items: center; gap: .25rem; }
.lang-switch form { display: inline; }
.lang-switch button {
background: transparent; border: 1px solid transparent; color: var(--muted);
padding: .3rem .5rem; border-radius: .4rem; cursor: pointer; font-size: .8rem;
}
.lang-switch button:hover { color: var(--text); background: var(--surface-2); }
.lang-switch button.active { color: var(--brand); border-color: var(--border); font-weight: 700; }
main.site { flex: 1; padding: 2.5rem 0; }
footer.site {
border-top: 1px solid var(--border); padding: 1.25rem 0; font-size: .85rem; color: var(--muted);
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center;
}
footer.site .links { display: flex; gap: 1rem; flex-wrap: wrap; }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: 1rem;
padding: 1.5rem; box-shadow: 0 10px 30px rgba(0,0,0,.25);
}
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: .4rem; font-size: .85rem; color: var(--muted); }
.field input[type="text"], .field input[type="email"], .field input[type="password"], .field input[type="number"], .field select {
width: 100%; padding: .7rem .85rem; border-radius: .55rem; border: 1px solid var(--border);
background: #0b1220; color: var(--text); font-size: 1rem;
}
.field input:focus, .field select:focus { outline: 2px solid var(--brand); border-color: transparent; }
.error { color: var(--danger); font-size: .8rem; margin-top: .3rem; }
.alert { padding: .85rem 1rem; border-radius: .6rem; margin-bottom: 1.25rem; font-size: .9rem; }
.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.4); color: #fecaca; }
.alert-ok { background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.4); color: #bbf7d0; }
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 700; }
.badge-admin { background: rgba(99,102,241,.2); color: #a5b4fc; }
.badge-user { background: rgba(14,165,233,.2); color: #7dd3fc; }
.eye-toggle {
position: absolute; right: .6rem; top: 50%; transform: translateY(-50%);
background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: .2rem;
}
.pw-wrap { position: relative; }
@media (max-width: 640px) {
header.site { flex-direction: column; align-items: flex-start; }
footer.site { flex-direction: column; align-items: flex-start; }
}
</style>
@stack('styles')
</head>
<body>
<div class="container">
<header class="site">
<a href="{{ route('welcome') }}" class="logo">
<span class="mark">C10</span>
<span>{{ config('app.name', 'City10') }}</span>
</a>
<nav class="site">
@auth
<a href="{{ route('dashboard') }}">{{ __('Dashboard') }}</a>
<a href="{{ route('city.viewer') }}">{{ __('nav.city') }}</a>
@if (auth()->user()->isAdmin())
<a href="{{ route('admin.stores.index') }}" class="badge badge-admin" style="text-decoration:none;">{{ __('nav.admin') }}</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="btn">{{ __('Logout') }}</button>
</form>
@else
<a href="{{ route('login') }}">{{ __('Login') }}</a>
<a href="{{ route('register') }}" class="btn-primary">{{ __('Register') }}</a>
@endauth
<span class="lang-switch">
@foreach (['pt_BR' => 'PT', 'en' => 'EN', 'es' => 'ES'] as $code => $label)
<form method="GET" action="{{ route('lang.switch') }}">
<input type="hidden" name="locale" value="{{ $code }}">
<button type="submit" class="{{ app()->getLocale() === $code ? 'active' : '' }}">{{ $label }}</button>
</form>
@endforeach
</span>
</nav>
</header>
<main class="site">
@if (session('status'))
<div class="alert alert-ok">{{ session('status') }}</div>
@endif
@if ($errors->any())
<div class="alert alert-error">
<ul style="margin:0; padding-left: 1.1rem;">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('content')
</main>
<footer class="site">
<div>
&copy; {{ date('Y') }} - Todos os direitos reservados - Powered by
<a href="https://syspanel.com.br" target="_blank" rel="noopener">Syspanel - www.syspanel.com.br</a>
</div>
<div class="links">
<a href="{{ route('legal.show', 'terms') }}">Termos de Uso</a>
<a href="{{ route('legal.show', 'cookies') }}">Cookies</a>
<a href="{{ route('legal.show', 'privacy') }}">Privacidade</a>
</div>
</footer>
</div>
{{-- Cookie consent banner (LGPD): shown until the visitor makes a choice. --}}
@unless (request()->cookie('city10-consent'))
<div id="cookie-banner" style="position:fixed; left:1rem; right:1rem; bottom:1rem; z-index:50; max-width:720px; margin:0 auto; background:var(--surface); border:1px solid var(--border); border-radius:1rem; padding:1.25rem; box-shadow:0 20px 50px rgba(0,0,0,.5);">
<p style="margin:0 0 .5rem; color:#fff; font-weight:600;">🍪 Uso de Cookies</p>
<p style="margin:0 0 .9rem; color:var(--muted); font-size:.9rem; line-height:1.55;">
Utilizamos cookies estritamente necessários para o funcionamento da sessão e, com o seu consentimento,
cookies de desempenho para melhorar a experiência. Ao continuar, você concorda com a nossa
<a href="{{ route('legal.show', 'terms') }}" target="_blank">Política de Termos de Uso e Privacidade</a>
e com a nossa <a href="{{ route('legal.show', 'cookies') }}" target="_blank">Política de Cookies</a>.
</p>
<div style="display:flex; gap:.6rem; flex-wrap:wrap;">
<button type="button" class="btn btn-primary" data-consent="all">Aceitar todos</button>
<button type="button" class="btn" data-consent="necessary">Recusar opcionais</button>
<a href="{{ route('legal.show', 'cookies') }}" class="btn" target="_blank">Personalizar</a>
</div>
</div>
<script>
// Persists the cookie consent choice for 1 year (LGPD art. 7º, I).
document.querySelectorAll('#cookie-banner [data-consent]').forEach(function (btn) {
btn.addEventListener('click', function () {
var value = btn.dataset.consent === 'all' ? 'all' : 'necessary';
var expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = 'city10-consent=' + value + '; expires=' + expires + '; path=/; SameSite=Lax';
var banner = document.getElementById('cookie-banner');
if (banner) banner.remove();
});
});
</script>
@endunless
@stack('scripts')
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
@extends('layouts.app')
@section('title', 'Aceite dos Termos')
@section('content')
<div style="max-width: 820px; margin: 0 auto;">
<div class="card">
<h1 style="margin-top:0; color:#fff;">📄 Aceite dos Termos de Uso e Política de Privacidade</h1>
<p style="color:var(--muted);">
Antes de acessar a plataforma, você precisa ler e aceitar os documentos legais abaixo.
Este registro é auditável (data, hora, versão e endereço IP), conforme a LGPD.
</p>
<form method="POST" action="{{ route('terms.accept.store') }}">
@csrf
@foreach ($documents as $doc)
<section style="margin: 1.5rem 0; padding: 1rem; border:1px solid var(--border); border-radius:.8rem; background:#0b1220;">
<h3 style="margin:0 0 .75rem; color:#fff;">{{ $doc['title'] }} <span style="color:var(--muted); font-weight:400; font-size:.85rem;">(v{{ $doc['version'] }})</span></h3>
<div style="max-height: 260px; overflow-y:auto; padding-right:.5rem; color:var(--text); line-height:1.6; font-size:.92rem;">
{!! \Illuminate\Support\Str::markdown($doc['body'], ['html_input' => 'escape', 'allow_unsafe_links' => false]) !!}
</div>
<label style="display:flex; align-items:flex-start; gap:.6rem; margin-top:.9rem; font-size:.92rem; cursor:pointer;">
<input type="checkbox" name="accepted_{{ $doc['type'] }}" value="1" required style="margin-top:.2rem;">
<span>Li e aceito o documento <strong>{{ $doc['title'] }}</strong> (versão {{ $doc['version'] }}).</span>
</label>
@error('accepted_' . $doc['type'])<div class="error">{{ $message }}</div>@enderror
</section>
@endforeach
<button type="submit" class="btn btn-primary" style="padding:.8rem 1.6rem;">Aceitar e Continuar</button>
</form>
</div>
</div>
@endsection
+41
View File
@@ -0,0 +1,41 @@
@extends('layouts.app')
@section('title', 'Histórico de Consentimentos')
@section('content')
<div style="max-width: 820px; margin: 0 auto;">
<div class="card">
<h1 style="margin-top:0; color:#fff;">🗂️ Histórico de Consentimentos</h1>
<p style="color:var(--muted);">Registro auditável de todas as versões dos documentos legais que você aceitou.</p>
@if ($history->isEmpty())
<p style="color:var(--muted);">Nenhum consentimento registrado ainda.</p>
@else
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:.9rem;">
<thead>
<tr style="text-align:left; color:var(--muted); border-bottom:1px solid var(--border);">
<th style="padding:.6rem .4rem;">Documento</th>
<th style="padding:.6rem .4rem;">Versão</th>
<th style="padding:.6rem .4rem;">Aceito em</th>
<th style="padding:.6rem .4rem;">IP</th>
<th style="padding:.6rem .4rem;">Canal</th>
</tr>
</thead>
<tbody>
@foreach ($history as $entry)
<tr style="border-bottom:1px solid var(--border);">
<td style="padding:.6rem .4rem;">{{ $entry->document?->type }}</td>
<td style="padding:.6rem .4rem;">{{ $entry->document?->version }}</td>
<td style="padding:.6rem .4rem;">{{ $entry->accepted_at?->format('d/m/Y H:i:s') }}</td>
<td style="padding:.6rem .4rem;">{{ $entry->ip_address }}</td>
<td style="padding:.6rem .4rem;">{{ $entry->source }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
@endsection
+16
View File
@@ -0,0 +1,16 @@
@extends('layouts.app')
@section('title', $title)
@section('content')
<article class="card" style="max-width: 820px; margin: 0 auto;">
<header style="margin-bottom: 1rem;">
<h1 style="margin:0 0 .35rem; color:#fff;">{{ $title }}</h1>
<p style="margin:0; color:var(--muted); font-size:.85rem;">Versão {{ $document->version }} — Vigente desde {{ $document->published_at?->format('d/m/Y H:i') }}</p>
</header>
<div class="legal-body" style="color:var(--text); line-height:1.65; font-size:.98rem;">
{!! \Illuminate\Support\Str::markdown($body, ['html_input' => 'escape', 'allow_unsafe_links' => false]) !!}
</div>
</article>
@endsection
+53
View File
@@ -0,0 +1,53 @@
@extends('layouts.app')
@section('title', 'City10 — Cidade 3D Virtual com Agentes de IA')
@section('content')
<div class="hero" style="text-align:center; padding: 2.5rem 0 1.5rem;">
<h1 style="font-size: clamp(2rem, 5vw, 3.2rem); margin: 0 0 .75rem; color:#fff; line-height:1.1;">
City<span style="color: var(--brand);">10</span>
</h1>
<p style="color: var(--muted); max-width: 640px; margin: 0 auto 1.5rem; font-size: 1.05rem;">
{{ __('welcome.tagline') }}
</p>
@auth
<a href="{{ route('dashboard') }}" class="btn btn-primary" style="display:inline-block; padding:.8rem 1.6rem;">{{ __('Dashboard') }}</a>
@else
<a href="{{ route('login') }}" class="btn btn-primary" style="display:inline-block; padding:.8rem 1.6rem;">{{ __('Login') }}</a>
<a href="{{ route('register') }}" class="btn" style="display:inline-block; padding:.8rem 1.6rem;">{{ __('Register') }}</a>
@endauth
</div>
<div class="grid-3" style="margin-top:1.5rem;">
<div class="card">
<h3 style="margin-top:0; color:#fff;">{{ __('welcome.card1.title') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">{{ __('welcome.card1.desc') }}</p>
</div>
<div class="card">
<h3 style="margin-top:0; color:#fff;">{{ __('welcome.card2.title') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">{{ __('welcome.card2.desc') }}</p>
</div>
<div class="card">
<h3 style="margin-top:0; color:#fff;">{{ __('welcome.card3.title') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">{{ __('welcome.card3.desc') }}</p>
</div>
</div>
<div class="card" style="margin-top:1.5rem;">
<h3 style="margin-top:0; color:#fff;">{{ __('welcome.city.title') }}</h3>
<p style="color:var(--muted); font-size:.95rem;">
{{ __('welcome.city.desc') }}
</p>
</div>
@endsection
@push('styles')
<style>
.hero { animation: fadeUp .6s ease both; }
.grid-3 .card { animation: fadeUp .6s ease both; }
.grid-3 .card:nth-child(2) { animation-delay: .08s; }
.grid-3 .card:nth-child(3) { animation-delay: .16s; }
@keyframes fadeUp { from { opacity:0; transform: translateY(14px);} to {opacity:1; transform:none;} }
</style>
@endpush