diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx index c46b176..ff118bd 100644 --- a/frontend/src/admin/AdminApp.tsx +++ b/frontend/src/admin/AdminApp.tsx @@ -25,6 +25,11 @@ import { type EditableAdminStage, type FamilyId, } from './lib/familyConfigs' +import { + getDefaultAdminStagePatchForGame, + getMissionTemplateById, + type MissionTemplateId, +} from './lib/gameCatalog' import { buildPlayerDrafts, normalizePlayerId, @@ -40,8 +45,37 @@ import { getStablePlayerColor, getPlayerInitials } from '../shared/playerIdentit type LoadState = 'loading' | 'ready' | 'error' type OverviewState = 'locked' | 'loading' | 'ready' | 'error' -type CmsPanel = 'none' | 'players' | 'mission' | 'labels' - +type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' | 'builder' + + +function slugifyMissionItemId(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) +} + +function buildTemplatePhysicalFields(kind: 'collectible' | 'requirement' | 'clue' | 'bonus', label: string) { + const itemId = slugifyMissionItemId(label) || 'objeto_qr' + const payload = `saga:item:${itemId}` + + return { + physical_node_kind: kind, + physical_item_kind: kind, + physical_item_id: itemId, + physical_item_label: label, + physical_qr: { + kind, + item_id: itemId, + label, + payload, + }, + qr_payload: payload, + } +} function preservePhysicalStageFields>(previous: T, next: T): T { const keys = [ @@ -614,6 +648,104 @@ export default function AdminApp() { setLocalNotice('Local preview updated. Save changes to persist.') } + function applyMissionTemplate(templateId: MissionTemplateId) { + if (!overview) return + + const template = getMissionTemplateById(templateId) + const shouldReplace = + stages.length === 0 || + window.confirm(`Reemplazar la ruta local actual por la plantilla "${template.title}"? Guarda después para persistir.`) + + if (!shouldReplace) return + + const mapCenter = + overview?.config?.map_center || + config?.map_center || + ([40.4168, -3.7038] as [number, number]) + + const centerLat = Number(mapCenter[0] || 40.4168) + const centerLon = Number(mapCenter[1] || -3.7038) + let lastPhysicalItem: { id: string; label: string } | null = null + + const nextStages = template.stages.map((item, index) => { + const patch = getDefaultAdminStagePatchForGame(item.gameId) + const lat = centerLat + item.offsetLat + const lon = centerLon + item.offsetLon + const physicalFields = item.physicalKind + ? buildTemplatePhysicalFields(item.physicalKind, item.itemLabel || item.title) + : {} + + if (item.physicalKind) { + const record = physicalFields as { physical_item_id?: string; physical_item_label?: string } + lastPhysicalItem = { + id: record.physical_item_id || slugifyMissionItemId(item.itemLabel || item.title), + label: record.physical_item_label || item.itemLabel || item.title, + } + } + + const requirementConfig = + item.requiresPreviousItem && lastPhysicalItem + ? { + required_item_id: lastPhysicalItem.id, + required_item_label: lastPhysicalItem.label, + required_item_quantity: 1, + required_item_consume: false, + } + : {} + + return { + id: `local-template-${Date.now()}-${index}`, + index, + title: item.title, + type: patch.type, + label: patch.label, + icon: patch.icon, + lat, + lon, + radius: item.radius || 50, + entry_mode: 'gps', + require_proximity: true, + has_hint: false, + has_manual_fallback: false, + content: item.content || patch.content, + objective: patch.objective, + config: { + ...patch.config, + ...requirementConfig, + }, + config_summary: Array.from(new Set([...patch.config_summary, ...Object.keys(requirementConfig)])), + messages: patch.messages, + ...physicalFields, + } as EditableAdminStage + }) + + const familyCounts = nextStages.reduce>((acc, stage) => { + const family = stage.type || 'signal_hunt' + acc[family] = (acc[family] || 0) + 1 + return acc + }, {}) + + setOverview((current) => current + ? { + ...current, + stages: nextStages, + counts: current.counts + ? { + ...current.counts, + stages: nextStages.length, + family_counts: familyCounts, + } + : current.counts, + } + : current + ) + + setSelectedStage(nextStages[0] || null) + setCmsPanel('none') + setSaveState('idle') + setLocalNotice(`Plantilla "${template.title}" creada en local. Revisa los nodos y pulsa Guardar.`) + } + function createLocalNodeAt(lat?: number, lon?: number) { const mapCenter = overview?.config?.map_center || @@ -774,6 +906,7 @@ export default function AdminApp() { onSavePlayers={savePlayerProfiles} onUpdateMissionDraft={updateMissionDraft} onSaveSettings={saveMissionSettings} + onApplyMissionTemplate={applyMissionTemplate} /> ) diff --git a/frontend/src/admin/components/AdminMissionControlShell.tsx b/frontend/src/admin/components/AdminMissionControlShell.tsx index bdc5c38..73e5251 100644 --- a/frontend/src/admin/components/AdminMissionControlShell.tsx +++ b/frontend/src/admin/components/AdminMissionControlShell.tsx @@ -5,13 +5,15 @@ import NodeDetailDrawer from './NodeDetailDrawer' import NodePhysicalTypePanel from './NodePhysicalTypePanel' import PlayersPanel from './PlayersPanel' import SettingsPanel from './SettingsPanel' +import MissionBuilderPanel from './MissionBuilderPanel' import type { AdminReactOverviewProfile, AdminReactOverviewStage } from '../lib/adminApi' import { familyCards } from '../lib/familyConfigs' +import type { MissionTemplateId } from '../lib/gameCatalog' import type { PlayerDraft } from '../lib/playerDrafts' import { getPhysicalNodeVisual } from '../lib/physicalNodeVisuals' import '../styles/admin-modern-shell.css' -type CmsPanel = 'none' | 'players' | 'mission' | 'labels' +type CmsPanel = 'none' | 'players' | 'mission' | 'labels' | 'builder' | 'builder' type SaveState = 'idle' | 'saving' | 'saved' | 'error' type AdminMissionControlShellProps = { @@ -47,6 +49,7 @@ type AdminMissionControlShellProps = { onSavePlayers: () => void onUpdateMissionDraft: (key: string, value: string) => void onSaveSettings: () => void + onApplyMissionTemplate: (templateId: MissionTemplateId) => void } function selectedStageKey(stage: AdminReactOverviewStage | null) { @@ -92,6 +95,7 @@ export default function AdminMissionControlShell({ onSavePlayers, onUpdateMissionDraft, onSaveSettings, + onApplyMissionTemplate, }: AdminMissionControlShellProps) { const [typeChooserStageKey, setTypeChooserStageKey] = useState(null) @@ -171,7 +175,7 @@ export default function AdminMissionControlShell({
-
@@ -287,7 +291,7 @@ export default function AdminMissionControlShell({
-
+ {cmsPanel === 'builder' ? ( + { + onSetCmsPanel('none') + onCreateNode() + }} + onApplyTemplate={onApplyMissionTemplate} + /> + ) : null} + {cmsPanel === 'players' ? ( - + + diff --git a/frontend/src/admin/components/FamiliesPanel.tsx b/frontend/src/admin/components/FamiliesPanel.tsx index 58a527f..9caa82d 100644 --- a/frontend/src/admin/components/FamiliesPanel.tsx +++ b/frontend/src/admin/components/FamiliesPanel.tsx @@ -1,16 +1,29 @@ import { familyCards } from '../lib/familyConfigs' +import { adminGameCatalog } from '../lib/gameCatalog' export default function FamiliesPanel() { return (
- Families / labels - Available family-native runtime labels. + Juegos disponibles + 13 plantillas editables. Internamente usan 3 motores estables: GPS, brújula y lógica. + +
+ {adminGameCatalog.map((game) => ( +
+ {game.icon} {game.title} + {game.difficulty} · {game.duration} · {game.summary} +
+ ))} +
+ + Motores internos + Estos son los runtimes que ejecuta el player actualmente.
{familyCards.map((family) => (
{family.icon} {family.title} - {family.id} + {family.id} · {family.detail}
))}
diff --git a/frontend/src/admin/components/MissionBuilderPanel.tsx b/frontend/src/admin/components/MissionBuilderPanel.tsx new file mode 100644 index 0000000..f26c74f --- /dev/null +++ b/frontend/src/admin/components/MissionBuilderPanel.tsx @@ -0,0 +1,64 @@ +import { missionTemplates, type MissionTemplateId } from '../lib/gameCatalog' +import type { AdminReactOverviewStage } from '../lib/adminApi' + +type MissionBuilderPanelProps = { + stages: AdminReactOverviewStage[] + onCreateNode: () => void + onApplyTemplate: (templateId: MissionTemplateId) => void +} + +export default function MissionBuilderPanel({ + stages, + onCreateNode, + onApplyTemplate, +}: MissionBuilderPanelProps) { + return ( +
+ Crear contenido + + Crea un nodo suelto para editarlo a mano, o arranca una plantilla completa de misión. + Nada se guarda hasta pulsar Guardar. + + + + + {stages.length > 0 ? ( +
+ Las plantillas reemplazan la ruta local visible. No se persiste nada hasta pulsar Guardar. +
+ ) : null} + +
+ {missionTemplates.map((template) => ( +
+
+ {template.icon} +
+ {template.title} + {template.goodFor} +
+
+ +

{template.summary}

+ +
    + {template.stages.map((stage) => ( +
  1. {stage.title}
  2. + ))} +
+ + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/admin/components/NodeDetailDrawer.tsx b/frontend/src/admin/components/NodeDetailDrawer.tsx index c338af3..6860b6f 100644 --- a/frontend/src/admin/components/NodeDetailDrawer.tsx +++ b/frontend/src/admin/components/NodeDetailDrawer.tsx @@ -8,6 +8,12 @@ import { type EditableAdminStage, type FamilyId, } from '../lib/familyConfigs' +import { + adminGameCatalog, + getAdminGameForStage, + getDefaultAdminStagePatchForGame, + type AdminGameId, +} from '../lib/gameCatalog' type DrawerTab = 'basics' | 'location' | 'game' | 'requirement' | 'messages' | 'advanced' @@ -58,29 +64,78 @@ function getPhysicalRequirementOption(stage: AdminReactOverviewStage): PhysicalR physical_item_id?: string physical_item_label?: string physical_qr?: { item_id?: string; label?: string; kind?: string } + qr_payload?: string + label?: string + icon?: string } - const kind = record.physical_node_kind || record.physical_item_kind || record.physical_qr?.kind + const config = + typeof (stage as EditableAdminStage).config === 'object' && (stage as EditableAdminStage).config !== null + ? (((stage as EditableAdminStage).config || {}) as Record) + : {} + + const gameId = typeof config.game_id === 'string' ? config.game_id : '' + const labelText = String(record.label || stage.title || '').toLowerCase() + const titleText = String(stage.title || '').toLowerCase() + const payloadText = String(record.qr_payload || record.physical_qr?.item_id || record.physical_qr?.label || '').toLowerCase() + const gameText = String(gameId || config.game_title || config.objective || '').toLowerCase() + const allText = `${labelText} ${titleText} ${payloadText} ${gameText}` + + const catalogKind = + gameId === 'qr_collectible' + ? 'collectible' + : gameId === 'qr_key_gate' + ? 'requirement' + : gameId === 'clue_card' + ? 'clue' + : gameId === 'bonus_cache' + ? 'bonus' + : '' + + const inferredKind = + /llave|key|qr_key|requirement/.test(allText) + ? 'requirement' + : /pista|clue/.test(allText) + ? 'clue' + : /bonus|regalo|cache/.test(allText) + ? 'bonus' + : /objeto|coleccionable|collectible|qr/.test(allText) + ? 'collectible' + : '' + + const kind = record.physical_node_kind || record.physical_item_kind || record.physical_qr?.kind || catalogKind || inferredKind if (kind !== 'collectible' && kind !== 'requirement' && kind !== 'clue' && kind !== 'bonus') return null - const label = String( + const title = String(stage.title || `Nodo ${stage.index + 1}`).trim() + const typeLabel = String( record.physical_item_label || record.physical_qr?.label || - stage.title || - `Nodo ${stage.index + 1}` + config.physical_item_label || + config.game_title || + record.label || + ( + kind === 'requirement' + ? 'Llave QR' + : kind === 'clue' + ? 'Pista QR' + : kind === 'bonus' + ? 'Bonus QR' + : 'Objeto QR' + ) ).trim() const itemId = String( record.physical_item_id || record.physical_qr?.item_id || - slugifyRequirementItemId(label) || + config.physical_item_id || + slugifyRequirementItemId(typeLabel || title) || `node_${stage.index + 1}` ).trim() return { itemId, - label: label || itemId, - title: stage.title || label || itemId, + label: title || typeLabel || itemId, + title: typeLabel && typeLabel !== title ? `${title} · ${typeLabel}` : title, kind, icon: kind === 'collectible' @@ -132,6 +187,7 @@ export default function NodeDetailDrawer({ const selectedRequirement = physicalRequirementOptions.find( (item) => item.itemId === getDraftConfigText('required_item_id') ) + const selectedGame = getAdminGameForStage(draft.type, draftConfig) function getDraftConfigText(key: string, fallback = '') { const value = draftConfig[key] @@ -203,6 +259,30 @@ export default function NodeDetailDrawer({ updateDraftConfig('sequence', parts.length > 0 ? parts : value) } + function handleDraftGameChange(nextGameId: AdminGameId) { + const patch = getDefaultAdminStagePatchForGame(nextGameId) + + updateDraftLocal((current) => ({ + ...(current as EditableAdminStage), + type: patch.type, + label: patch.label, + icon: patch.icon, + objective: patch.objective, + config: { + ...patch.config, + ...(((current as EditableAdminStage).config || {}) as Record), + game_id: nextGameId, + game_title: patch.label, + }, + config_summary: Object.keys(patch.config), + content: current.content || patch.content, + messages: { + ...(patch.messages || {}), + ...(current.messages || {}), + }, + })) + } + function handleDraftFamilyChange(nextType: FamilyId) { const nextConfig = getDefaultAdminConfigForFamily(nextType) @@ -231,19 +311,33 @@ export default function NodeDetailDrawer({ aria-label={`Node editor: ${draft.title}`} onClick={(event) => event.stopPropagation()} > -
-
- {isLocalNew ? 'Add node' : 'Node editor'} -

{draft.index + 1}. {draft.title || 'Untitled node'}

-
- {family?.icon || '◇'} {draft.label || draft.type} - {formatCoords(draft.lat, draft.lon)} - {typeof draft.radius === 'number' ? `${draft.radius}m radius` : 'No radius'} +
+
+ {isLocalNew ? 'Añadir nodo' : 'Editor de nodo'} + + +
+ +
+
+

{draft.index + 1}. {draft.title || 'Nodo sin título'}

+ +
+ {family?.icon || '◇'} {draft.label || draft.type} + {formatCoords(draft.lat, draft.lon)} + {typeof draft.radius === 'number' ? `${draft.radius} m` : 'Sin radio'} +
-
+
- -
-
+
@@ -324,17 +416,28 @@ export default function NodeDetailDrawer({ +
+ {selectedGame.icon} +
+ {selectedGame.title} +

{selectedGame.summary}

+ {selectedGame.playerGoal} +
+
+