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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
© {{ 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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+53
@@ -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
|
||||
Reference in New Issue
Block a user