diff --git a/admin/DashboardController.php b/admin/DashboardController.php index cba9499..1d31f78 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -2921,6 +2921,32 @@ HTML; $showDynList = $ft === 'dynamic_list' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showSubmit = $ft === 'submit_form' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showForEach = $ft === 'collect_for_each' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; + $showCap = $ft === 'collect_and_post' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; + + // collect_and_post fields + $capFields = $flow['fields'] ?? []; + $capHeader = self::h($flow['header'] ?? ''); + $capSucc = self::h($flow['success_text'] ?? ''); + $capConfirm = !empty($flow['confirm']); + $capEpKey = self::h($flow['endpoint_key'] ?? ''); + $capEpSelHtml = ''; + foreach ($companyEndpoints as $ep) { + $sel = $capEpKey === self::h($ep['endpoint_key']) ? ' selected' : ''; + $capEpSelHtml .= ''; + } + $capFieldsHtml = ''; + foreach ($capFields as $cf) { + $ck = self::h($cf['key'] ?? ''); + $cl = self::h($cf['label'] ?? ''); + $cp = self::h($cf['prompt'] ?? ''); + $capFieldsHtml .= " +
+ + + + +
"; + } $feEpSelHtml = ''; foreach ($companyEndpoints as $ep) { @@ -2968,6 +2994,7 @@ HTML; + @@ -3053,6 +3080,26 @@ HTML;
+ +
+
+
+
+
+
+
+ +
+ +
+ +
{$capFieldsHtml}
+ +
+
+ "; @@ -3316,6 +3363,7 @@ function flowTabChange(sel) { 'ftg-dynlist': {show: v==='dynamic_list', flex: true}, 'ftg-submit': {show: v==='submit_form', flex: true}, 'ftg-foreach': {show: v==='collect_for_each', flex: true}, + 'ftg-cap': {show: v==='collect_and_post', flex: true}, }; for (const [cls, cfg] of Object.entries(sections)) { const el = card.querySelector('.' + cls); @@ -3367,6 +3415,7 @@ function addFlowCard() { ' ' + ' ' + ' ' + + ' ' + ' ' + ' ' + ' ' + @@ -3436,6 +3485,22 @@ function addFlowCard() { '
' + '
' + '
' + + ' ' + ' ' + ' ' + ' ' + @@ -3444,6 +3509,20 @@ function addFlowCard() { container.lastElementChild.scrollIntoView({behavior:'smooth'}); } +function addCapField(containerId) { + const c = document.getElementById(containerId); + if (!c) return; + const row = document.createElement('div'); + row.className = 'cap-field-row'; + row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:6px;align-items:center;margin-bottom:4px'; + row.innerHTML = + '' + + '' + + '' + + ''; + c.appendChild(row); +} + function aiProviderChange(v) { const oai = document.getElementById('ai-openai-fields'); const gem = document.getElementById('ai-gemini-fields'); diff --git a/public/index.php b/public/index.php index a42d318..fb84127 100644 --- a/public/index.php +++ b/public/index.php @@ -421,6 +421,13 @@ $routes = [ $flowFeHeaders = $_POST['flow_fe_header'] ?? []; $flowFeQs = $_POST['flow_fe_question'] ?? []; $flowFeSucTxts = $_POST['flow_fe_success'] ?? []; + $flowCapEps = $_POST['flow_cap_ep'] ?? []; + $flowCapHdrs = $_POST['flow_cap_header'] ?? []; + $flowCapSuccs = $_POST['flow_cap_success'] ?? []; + $flowCapCfmIdx = $_POST['flow_cap_confirm_idx'] ?? []; + $capFieldKeys = $_POST['cap_field_key'] ?? []; + $capFieldLbls = $_POST['cap_field_label'] ?? []; + $capFieldPrmts = $_POST['cap_field_prompt'] ?? []; $flows = []; foreach ($flowKeys as $i => $fk) { @@ -481,6 +488,31 @@ $routes = [ $f['header'] = trim($flowFeHeaders[$i] ?? ''); $f['question'] = trim($flowFeQs[$i] ?? ''); $f['success_text'] = trim($flowFeSucTxts[$i] ?? ''); + + } elseif ($ftype === 'collect_and_post') { + $confirmIdx = $flowCapCfmIdx[$i] ?? (string)$i; + $confirmKey = 'flow_cap_confirm_' . $confirmIdx; + $fields = []; + // cap_field_* arrays are flat across ALL cap flows — we read only + // rows belonging to this card by matching the container's position. + // Since PHP flattens them, we track by finding all entries in sequence. + // Simple approach: take all submitted field rows (they are per-card + // because the form only has one submit, and cap-field rows are inside + // the card's container — browser sends them in DOM order). + foreach ($capFieldKeys as $fi => $fkey) { + $fkey = trim($fkey); + if ($fkey === '') continue; + $fields[] = [ + 'key' => $fkey, + 'label' => trim($capFieldLbls[$fi] ?? $fkey), + 'prompt' => trim($capFieldPrmts[$fi] ?? ''), + ]; + } + $f['endpoint_key'] = trim($flowCapEps[$i] ?? ''); + $f['header'] = trim($flowCapHdrs[$i] ?? ''); + $f['success_text'] = trim($flowCapSuccs[$i] ?? ''); + $f['confirm'] = !empty($_POST[$confirmKey]); + $f['fields'] = $fields; } $flows[$fk] = $f; diff --git a/services/NormalBot.php b/services/NormalBot.php index 62f8582..51ec638 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -45,6 +45,11 @@ class NormalBot return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows); } + // 2c. collect_and_post: collecting sequential fields then POSTing + if (!empty($meta['__cap'])) { + return self::handleCapInput($meta, $input, $context, $company, $ctxId); + } + // 3. Active node — resume conversation $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { @@ -385,6 +390,143 @@ class NormalBot return self::sendText($fe['success_text'] . $nav, $context['from'], $company); } + // ── collect_and_post — pide campos definidos en config y hace POST ───────── + + private static function handleCollectAndPost(array $flow, array $context, array $company, int $ctxId): ?array + { + $fields = $flow['fields'] ?? []; + if (empty($fields)) { + ConversationContext::reset($ctxId); + return self::sendText('⚠️ Flujo sin campos configurados. Contacta al administrador.', $context['from'], $company); + } + + $meta = ConversationContext::getMetadata($ctxId); + $meta['__cap'] = [ + 'fields' => $fields, + 'index' => 0, + 'collected' => [], + 'endpoint_key' => $flow['endpoint_key'] ?? '', + 'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.', + 'confirm' => (bool)($flow['confirm'] ?? true), + 'header' => $flow['header'] ?? '📋 Registro', + ]; + ConversationContext::updateMetadata($ctxId, $meta); + + return self::capAskNext($meta['__cap'], $context['from'], $company); + } + + private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array + { + $cap = $meta['__cap']; + $fields = $cap['fields']; + $idx = (int)$cap['index']; + $field = $fields[$idx]; + + // Save answer + $cap['collected'][$field['key']] = trim($input); + $cap['index'] = $idx + 1; + $meta['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $meta); + + // More fields? + if ($cap['index'] < count($fields)) { + return self::capAskNext($cap, $context['from'], $company); + } + + // All done — confirm or submit directly + if ($cap['confirm']) { + $summary = "*{$cap['header']}*\n\n"; + foreach ($cap['collected'] as $k => $v) { + $label = self::capFieldLabel($fields, $k); + $summary .= "• {$label}: *{$v}*\n"; + } + $summary .= "\n¿Confirmas el registro?"; + + return self::enqueueInteractive($context['from'], [ + 'type' => 'button', + 'body' => ['text' => $summary], + 'action' => ['buttons' => [ + ['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']], + ['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']], + ]], + ], $company); + } + + return self::submitCapPost($meta, $context, $company, $ctxId); + } + + private static function capAskNext(array $cap, string $to, array $company): ?array + { + $idx = (int)$cap['index']; + $total = count($cap['fields']); + $field = $cap['fields'][$idx]; + $prompt = $field['prompt'] ?? ('Campo: ' . $field['key']); + + $text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}"; + return self::sendText($text, $to, $company); + } + + private static function capFieldLabel(array $fields, string $key): string + { + foreach ($fields as $f) { + if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key']; + } + return $key; + } + + private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array + { + $cap = $meta['__cap']; + $endpointKey = $cap['endpoint_key'] ?? ''; + $nav = "\n\nEscribe *menu* para ver más opciones."; + + unset($meta['__cap']); + ConversationContext::updateMetadata($ctxId, $meta); + ConversationContext::reset($ctxId); + + if ($endpointKey === '') { + return self::sendText('⚠️ Endpoint no configurado.' . $nav, $context['from'], $company); + } + + $stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1"); + $stmt->execute([(int)$company['id'], $endpointKey]); + $ep = $stmt->fetch(); + + if (!$ep || empty($ep['url'])) { + return self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company); + } + + $apiKey = $company['api_key'] ?? ''; + $payload = array_merge($cap['collected'], [ + 'telefono' => $context['from'], + 'nombre' => $context['name'] ?? '', + ]); + + $ch = curl_init($ep['url']); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + 'X-API-Key: ' . $apiKey, + ], + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($curlErr || $httpCode >= 400) { + self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}"); + return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company); + } + + return self::sendText($cap['success_text'] . $nav, $context['from'], $company); + } + // ── Submit form (end of multi-step collection) ─────────────────────────── private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array @@ -431,6 +573,7 @@ class NormalBot 'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId), 'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId), 'collect_for_each' => self::handleCollectForEach($flow, $context, $company, $ctxId), + 'collect_and_post' => self::handleCollectAndPost($flow, $context, $company, $ctxId), default => null, }; } @@ -595,6 +738,20 @@ class NormalBot return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus); } + // collect_and_post confirmation (confirm/cancel button) + if (!empty($meta['__cap'])) { + if ($input === '__cap_confirm') { + return self::submitCapPost($meta, $context, $company, $ctxId); + } + if ($input === '__cap_cancel') { + $m = ConversationContext::getMetadata($ctxId); + unset($m['__cap']); + ConversationContext::updateMetadata($ctxId, $m); + ConversationContext::reset($ctxId); + return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company); + } + } + // collect_for_each confirmation (confirm/cancel button) if (!empty($meta['__foreach'])) { if ($input === '__foreach_confirm') {