Files
City10/resources/views/admin/stores/form.blade.php
T
marcocosta10000 79ce2a1825 feat(world): add persistent citizen simulation with needs, jobs, and social features
- Adds Character model with survival needs (hunger, thirst, energy) and state machine
- Implements city:tick world ticker for offline progression every minute
- Adds city territory expansion and building construction system
- Introduces job economy, inventory, and transaction audit trail
- Adds social graph (friends, marriage, children) and character chat
- Implements admin approval workflow for new registrations
- Adds hospital admission system for traffic accidents
- Includes comprehensive feature tests and world seeders
2026-10-17 21:46:23 -03:00

376 lines
17 KiB
PHP
Executable File
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