Files
City10/resources/views/admin/stores/form.blade.php
T
marcocosta10000 79d12fa2f4 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
2026-10-17 20:46:15 -03:00

376 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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