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') {