{
  "name": "Cliente Salvaje (muestra)",
  "nodes": [
    {
      "name": "Mensaje de un cliente",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "44c07c1c-956d-5174-a4d5-dea63eb56b5c",
      "parameters": {
        "httpMethod": "POST",
        "path": "cliente-salvaje",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "55f86783-ea05-5550-ac64-49a9bc5981ca"
    },
    {
      "name": "Procesar mensaje",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Utilidades compartidas por los motores de Cliente Salvaje (clientes y\n// reseñas): detección de idioma por palabras frecuentes y expresiones\n// regulares que entienden tildes. Sin require: el generador del flujo de n8n\n// pega este archivo delante de cada motor dentro del nodo Code.\n\nconst PALABRAS_IDIOMA = {\n  es: 'el la los las de que y en un una para por con no me mi es soy estoy necesito quiero hola tenéis mañana del al se lo desde buenas todo nos pero hasta eso vuestra vuestro he sin muy porque también',\n  en: 'the and i we you to for a an is are my our need want hi hello with from can of it be here hey us but why would they ever that this at on your have ve didn t s itself',\n  de: 'ich und der die das ein eine einen ist bin wir mit für aus brauche möchte hallo mein unser nicht auf er ab soll war nach des dass so zu außerdem von wurde trotzdem sehr aber weil hat den dem im',\n  fr: 'je le la les et de des un une pour est suis avec mon ma bonjour il me voudrais à du c est on faut était mais parce qu avait dans pas ne moins pendant très sont nous vous elle',\n  it: 'il lo la e di un una per sono mi ci con che non mio mia ciao vorrei da serve servono dall dopo ma dovrebbe più chiedo ha si però stato perché anche molto tutto',\n  nl: 'ik de het een en van voor we zijn wil mijn onze met uit hoi hallo is niet mag kunnen nog op aan maar waarom zelf terwijl om had hele geen dat bij moest kon heb ook te',\n};\nconst NOMBRE_IDIOMA = { es: 'español', en: 'inglés', de: 'alemán', fr: 'francés', it: 'italiano', nl: 'neerlandés' };\n\n// \\b de JavaScript solo entiende ASCII: con él, «vélos» o «Körbe» fallan.\nfunction re(fuente) {\n  return new RegExp('(?<![\\\\p{L}\\\\p{N}])(?:' + fuente + ')(?![\\\\p{L}\\\\p{N}])', 'giu');\n}\n\nfunction tokens(texto) {\n  return texto.toLowerCase().split(/[^\\p{L}\\p{N}]+/u).filter(Boolean);\n}\n\nfunction detectarIdioma(texto) {\n  const lista = tokens(texto);\n  const puntos = {};\n  for (const [idioma, palabras] of Object.entries(PALABRAS_IDIOMA)) {\n    const set = new Set(palabras.split(' '));\n    puntos[idioma] = lista.filter((t) => set.has(t)).length;\n  }\n  const orden = Object.entries(puntos).sort((a, b) => b[1] - a[1]);\n  const total = orden.reduce((s, [, n]) => s + n, 0) || 1;\n  const codigo = orden[0][0];\n  return { codigo, nombre: NOMBRE_IDIOMA[codigo], confianza: Math.round((orden[0][1] / total) * 100) };\n}\n\n// Motor de «Cliente Salvaje»: lee el mensaje de un cliente de un negocio de\n// alquiler (ficticio) y devuelve idioma, pedido, disponibilidad, presupuesto\n// y una respuesta redactada en su idioma.\n//\n// Es el mismo código que va en el nodo Code del flujo de n8n: el script\n// scripts/generar-cliente-salvaje-n8n.js lo copia tal cual. Por eso no usa\n// require ni nada del entorno de Node: el sandbox de n8n no lo permite.\n//\n// No hay IA aquí. Todo son reglas y listas de palabras, y funciona con los\n// escenarios de la web porque están escritos para ello. Con mensajes reales\n// de clientes, la detección del pedido la haría un modelo de lenguaje y este\n// código se quedaría con lo que se hace mejor sin él: stock, precios y reglas.\n\n\nconst NEGOCIO = { nombre: 'Rent Salvaje', base: 'Corralejo' };\n\n// Orden importante: lo específico antes que lo genérico. Cada coincidencia se\n// tacha del texto para que «E-Bikes» no cuente también como «bikes» ni\n// «macchina a 7 posti» como «macchina».\nconst CATALOGO = [\n  { clave: 'monovolumen', precioDia: 60, stock: 2, motor: true, alternativa: 'coche',\n    patrones: ['7[ -]?(?:seater|sitzer)(?: car)?', 'siebensitzer', '(?:voiture|monospace) (?:de |à )?7 places', '(?:macchina|auto) a 7 posti', '(?:coche|monovolumen) de 7 plazas', 'zevenpersoonsauto'],\n    nombres: { es: ['monovolumen de 7 plazas', 'monovolúmenes de 7 plazas'], en: ['7-seater car', '7-seater cars'], de: ['Siebensitzer', 'Siebensitzer'], fr: ['voiture 7 places', 'voitures 7 places'], it: ['auto a 7 posti', 'auto a 7 posti'], nl: ['zevenpersoonsauto', \"zevenpersoonsauto's\"] } },\n  { clave: 'descapotable', precioDia: 75, stock: 1, motor: true, alternativa: 'coche',\n    patrones: ['descapotables?', 'convertibles?', 'cabrios?', 'cabriolets?', 'décapotables?', 'decappottabil[ei]'],\n    nombres: { es: ['descapotable', 'descapotables'], en: ['convertible', 'convertibles'], de: ['Cabrio', 'Cabrios'], fr: ['décapotable', 'décapotables'], it: ['decappottabile', 'decappottabili'], nl: ['cabrio', \"cabrio's\"] } },\n  { clave: 'todoterreno', precioDia: 65, stock: 2, motor: true, alternativa: 'coche',\n    patrones: ['4 ?x ?4s?', 'geländewagen', 'todoterrenos?', 'jeeps?', 'fuoristrada', 'terreinwagens?'],\n    nombres: { es: ['todoterreno', 'todoterrenos'], en: ['4x4', '4x4s'], de: ['Geländewagen', 'Geländewagen'], fr: ['4x4', '4x4'], it: ['fuoristrada', 'fuoristrada'], nl: ['terreinwagen', 'terreinwagens'] } },\n  { clave: 'quad', precioDia: 70, stock: 4, motor: true, alternativa: 'buggy',\n    patrones: ['quads?'],\n    nombres: { es: ['quad', 'quads'], en: ['quad', 'quads'], de: ['Quad', 'Quads'], fr: ['quad', 'quads'], it: ['quad', 'quad'], nl: ['quad', 'quads'] } },\n  { clave: 'buggy', precioDia: 120, stock: 2, motor: true, alternativa: null,\n    patrones: ['buggys?', 'buggies'],\n    nombres: { es: ['buggy', 'buggies'], en: ['buggy', 'buggies'], de: ['Buggy', 'Buggys'], fr: ['buggy', 'buggys'], it: ['buggy', 'buggy'], nl: ['buggy', \"buggy's\"] } },\n  { clave: 'scooter', precioDia: 30, stock: 5, motor: true, alternativa: null,\n    patrones: ['scooters?', 'motos?', 'roller', 'motorin[oi]', 'vespas?'],\n    nombres: { es: ['scooter de 125 cc', 'scooters de 125 cc'], en: ['125cc scooter', '125cc scooters'], de: ['125er-Roller', '125er-Roller'], fr: ['scooter 125', 'scooters 125'], it: ['scooter 125', 'scooter 125'], nl: ['scooter 125cc', 'scooters 125cc'] } },\n  { clave: 'ebike', precioDia: 30, stock: 6, motor: false, alternativa: 'bici',\n    patrones: ['e-?bikes?', 'electric bikes?', 'bicis? eléctricas?', 'bicicletas? eléctricas?', 'vélos? électriques?', 'bici elettric(?:a|he)', 'biciclett[ae] elettric(?:a|he)', 'elektrische fiets(?:en)?', 'elektrofahrr[aä]d(?:er)?'],\n    nombres: { es: ['bici eléctrica', 'bicis eléctricas'], en: ['e-bike', 'e-bikes'], de: ['E-Bike', 'E-Bikes'], fr: ['vélo électrique', 'vélos électriques'], it: ['bici elettrica', 'bici elettriche'], nl: ['elektrische fiets', 'elektrische fietsen'] } },\n  { clave: 'silla', precioDia: 5, stock: 3, extra: true, alternativa: null,\n    patrones: ['sillas? (?:infantil(?:es)?|de bebé|para niños?)', '(?:child|car|baby) seats?', 'kindersitze?', 'sièges? (?:bébé|enfant)', 'seggiolin[oi]', 'kinderzitjes?'],\n    nombres: { es: ['silla infantil', 'sillas infantiles'], en: ['child seat', 'child seats'], de: ['Kindersitz', 'Kindersitze'], fr: ['siège enfant', 'sièges enfant'], it: ['seggiolino', 'seggiolini'], nl: ['kinderzitje', 'kinderzitjes'] } },\n  { clave: 'remolque', precioDia: 10, stock: 3, extra: true, alternativa: null,\n    patrones: ['(?:child |bike )?trailers?', 'fietskar(?:ren)?', 'fahrradanhänger', 'remorques?(?: vélo)?', 'carrellin[oi](?: per bici)?', 'remolques?(?: infantil(?:es)?)?'],\n    nombres: { es: ['remolque infantil para bici', 'remolques infantiles para bici'], en: ['bike trailer', 'bike trailers'], de: ['Fahrradanhänger', 'Fahrradanhänger'], fr: ['remorque vélo', 'remorques vélo'], it: ['carrellino per bici', 'carrellini per bici'], nl: ['fietskar', 'fietskarren'] } },\n  { clave: 'portatablas', precioDia: 5, stock: 6, extra: true, alternativa: null,\n    patrones: ['portatablas', '(?:surf |roof )racks?', 'galeries? de toit', 'dachträger', 'portapacchi', 'portatavol[ae]', 'dakdragers?'],\n    nombres: { es: ['portatablas', 'portatablas'], en: ['roof rack', 'roof racks'], de: ['Dachträger', 'Dachträger'], fr: ['galerie de toit', 'galeries de toit'], it: ['portapacchi', 'portapacchi'], nl: ['dakdrager', 'dakdragers'] } },\n  { clave: 'cesta', precioDia: 2, stock: 15, extra: true, alternativa: null,\n    patrones: ['cestas?', 'baskets?', 'korb', 'körbe', 'paniers?', 'cestin[oi]', 'mandjes?'],\n    nombres: { es: ['cesta', 'cestas'], en: ['basket', 'baskets'], de: ['Korb', 'Körbe'], fr: ['panier', 'paniers'], it: ['cestino', 'cestini'], nl: ['mandje', 'mandjes'] } },\n  { clave: 'nevera', precioDia: 4, stock: 8, extra: true, alternativa: null,\n    patrones: ['neveras?', 'cool ?box(?:es)?', 'coolers?', 'kühlbox(?:en)?', 'glacières?', 'borsa frigo', 'koelbox(?:en)?'],\n    nombres: { es: ['nevera', 'neveras'], en: ['cool box', 'cool boxes'], de: ['Kühlbox', 'Kühlboxen'], fr: ['glacière', 'glacières'], it: ['borsa frigo', 'borse frigo'], nl: ['koelbox', 'koelboxen'] } },\n  { clave: 'bici', precioDia: 12, stock: 10, motor: false, alternativa: null,\n    patrones: ['bicis?', 'bicicletas?', 'bikes?', 'bicycles?', 'fahrr[aä]d(?:er)?', 'vélos?', 'biciclett[ae]', 'fiets(?:en)?'],\n    nombres: { es: ['bici', 'bicis'], en: ['bike', 'bikes'], de: ['Fahrrad', 'Fahrräder'], fr: ['vélo', 'vélos'], it: ['bici', 'bici'], nl: ['fiets', 'fietsen'] } },\n  { clave: 'coche', precioDia: 35, stock: 8, motor: true, alternativa: null,\n    patrones: ['coches?', 'cars?', 'autos?', 'mietwagen', 'voitures?', 'macchin[ae]', \"auto's\"],\n    nombres: { es: ['coche', 'coches'], en: ['car', 'cars'], de: ['Auto', 'Autos'], fr: ['voiture', 'voitures'], it: ['auto', 'auto'], nl: ['auto', \"auto's\"] } },\n  { clave: 'surf', precioDia: 15, stock: 12, motor: false, alternativa: null,\n    patrones: ['surfboards?', 'surfbrett(?:er)?', 'planches? de surf', 'tavol[ae] da surf', 'tablas? de surf', 'surfplank(?:en)?'],\n    nombres: { es: ['tabla de surf', 'tablas de surf'], en: ['surfboard', 'surfboards'], de: ['Surfbrett', 'Surfbretter'], fr: ['planche de surf', 'planches de surf'], it: ['tavola da surf', 'tavole da surf'], nl: ['surfplank', 'surfplanken'] } },\n  { clave: 'neopreno', precioDia: 7, stock: 20, extra: true, alternativa: null,\n    patrones: ['neoprenos?', 'wetsuits?', 'neoprenanz(?:ug|üge)', 'combinaisons?', 'mut[ae]'],\n    nombres: { es: ['neopreno', 'neoprenos'], en: ['wetsuit', 'wetsuits'], de: ['Neoprenanzug', 'Neoprenanzüge'], fr: ['combinaison', 'combinaisons'], it: ['muta', 'mute'], nl: ['wetsuit', 'wetsuits'] } },\n  { clave: 'kayak', precioDia: 30, stock: 4, motor: false, alternativa: 'sup',\n    patrones: ['kayaks?', 'kajaks?'],\n    nombres: { es: ['kayak', 'kayaks'], en: ['kayak', 'kayaks'], de: ['Kajak', 'Kajaks'], fr: ['kayak', 'kayaks'], it: ['kayak', 'kayak'], nl: ['kajak', 'kajaks'] } },\n  { clave: 'sup', precioDia: 22, stock: 6, motor: false, alternativa: 'kayak',\n    patrones: ['paddle ?surfs?', 'stand ?up ?paddles?', 'paddle ?boards?', 'sup(?:-boards?)?'],\n    nombres: { es: ['tabla de paddle surf', 'tablas de paddle surf'], en: ['paddleboard', 'paddleboards'], de: ['SUP-Board', 'SUP-Boards'], fr: ['paddle', 'paddles'], it: ['tavola da SUP', 'tavole da SUP'], nl: ['SUP-board', 'SUP-boards'] } },\n];\n\nconst ZONAS = [\n  { clave: 'corralejo', precio: 0, patron: 'corralejo', lugar: 'Corralejo' },\n  { clave: 'norte', precio: 15, patron: 'el cotillo|lajares|la oliva|villaverde', lugar: null },\n  { clave: 'centro', precio: 20, patron: 'puerto del rosario|caleta de fuste|el castillo', lugar: null },\n  { clave: 'aeropuerto', precio: 20, patron: 'aeropuerto|airport|flughafen|aéroport|aeroporto|vliegveld', lugar: { es: 'aeropuerto', en: 'airport', de: 'Flughafen', fr: 'aéroport', it: 'aeroporto', nl: 'vliegveld' } },\n  { clave: 'sur', precio: 35, patron: 'costa calma|morro jable|gran tarajal|tarajalejo|jandía', lugar: null },\n];\n\nconst NUMEROS = {\n  un: 1, uno: 1, una: 1, a: 1, an: 1, one: 1, ein: 1, eine: 1, einen: 1, einem: 1, einer: 1, eines: 1, une: 1, een: 1, 'één': 1,\n  dos: 2, two: 2, zwei: 2, deux: 2, due: 2, twee: 2,\n  tres: 3, three: 3, drei: 3, trois: 3, tre: 3, drie: 3,\n  cuatro: 4, four: 4, vier: 4, quatre: 4, quattro: 4,\n  cinco: 5, five: 5, 'fünf': 5, cinq: 5, cinque: 5, vijf: 5,\n  seis: 6, six: 6, sechs: 6, sei: 6, zes: 6,\n  siete: 7, seven: 7, sieben: 7, sept: 7, sette: 7, zeven: 7,\n  ocho: 8, eight: 8, acht: 8, huit: 8, otto: 8,\n  nueve: 9, nine: 9, neun: 9, neuf: 9, nove: 9, negen: 9,\n  diez: 10, ten: 10, zehn: 10, dix: 10, dieci: 10, tien: 10,\n  catorce: 14, fourteen: 14, vierzehn: 14, quatorze: 14, quattordici: 14, veertien: 14,\n};\n\nconst URGENCIA = {\n  alta: \"in einer stunde|sofort|heute|jetzt|right now|today|tonight|asap|within an hour|in an hour|tout de suite|aujourd'hui|maintenant|ce soir|dans une heure|subito|adesso|oggi|stasera|tra un'ora|vandaag|nu meteen|binnen een uur|ya mismo|ahora|hoy|esta noche|dentro de una hora\",\n  media: 'morgen|tomorrow|demain|domani|mañana',\n};\n\nconst AVISOS = {\n  mascota: 'iguanas?|leguan|iguanes?|perros?|dogs?|hund|chiens?|can[ei]|honden?|hond|gatos?|cats?|katze|chats?|gatt[oi]|kat|loros?|parrots?|papagei|perroquets?|pappagall[oi]|papegaai|tortugas?|turtles?|schildkröte|tortues?|tartarughe|tartaruga|schildpad',\n  licencia: 'rijbewijs|führerschein|driving licen[cs]e|licen[cs]e|permis|patente|carnet|carné',\n  principiante: 'nuotare|swim|schwimmen|nager|nadar|zwemmen|ever surfed|nie gesurft|jamais surfé|mai fatto surf|nunca (?:he|hemos) hecho surf|nooit gesurft',\n  especial: 'petals|pétalos|rosenblätter|pétales|petali|rozenblaadjes',\n  dunas: 'dunes?|dünen|dunas|duinen',\n  lobos: 'lobos',\n};\n\nconst REVISAR = {\n  licencia: 'Carné de conducir: comprobar quién va a conducir',\n  principiante: 'Principiantes en el agua: ofrecer clase con monitor',\n  especial: 'Petición especial fuera de catálogo: confirmar con el cliente',\n};\n\n// --- utilidades ------------------------------------------------------------\n\nfunction numero(token) {\n  if (/^\\d+$/.test(token)) return Number(token);\n  return NUMEROS[token] || null;\n}\n\nfunction euros(n, idioma) {\n  const redondo = Math.round(n * 100) / 100;\n  const [entero, dec] = (Number.isInteger(redondo) ? String(redondo) : redondo.toFixed(2)).split('.');\n  const miles = idioma === 'en' ? ',' : '.';\n  const cifra = entero.replace(/\\B(?=(\\d{3})+(?!\\d))/g, miles) + (dec ? (idioma === 'en' ? '.' : ',') + dec : '');\n  if (idioma === 'en') return '€' + cifra;\n  return cifra + ' €';\n}\n\n// --- pasos -----------------------------------------------------------------\n\nfunction identificarPedido(texto) {\n  let resto = texto;\n  const lineas = [];\n  for (const item of CATALOGO) {\n    const patron = re(item.patrones.join('|'));\n    let cantidad = 0;\n    resto = resto.replace(patron, (coincidencia, pos, entero) => {\n      const antes = tokens(entero.slice(0, pos)).slice(-3).reverse();\n      let n = 1;\n      for (const t of antes) { const v = numero(t); if (v) { n = v; break; } }\n      cantidad = Math.max(cantidad, n);\n      return ' '.repeat(coincidencia.length); // tachado, conservando posiciones\n    });\n    if (cantidad) lineas.push({ clave: item.clave, cantidad });\n  }\n  return lineas;\n}\n\nfunction detectarDias(texto) {\n  const num = '(\\\\d+|' + Object.keys(NUMEROS).join('|') + ')';\n  const reglas = [\n    { patron: re(num + '\\\\s+(?:weeks?|wochen?|semaines?|settiman[ae]|semanas?|weken?)'), factor: 7 },\n    { patron: re('(?:weeks?|woche|semaine|settimana|semana|week)'), fijo: 7 },\n    { patron: re(num + '\\\\s+(?:days?|tage?n?|tag|jours?|journées?|giorn[oi]|días?|dagen?|dag)'), factor: 1 },\n    { patron: re('(?:\\\\d+\\\\s+(?:hours?|stunden?|heures?|ore|horas?|uur)|afternoon|nachmittag|après-midi|pomeriggio|middag)'), fijo: 1 },\n  ];\n  let mejor = null;\n  for (const r of reglas) {\n    r.patron.lastIndex = 0;\n    const m = r.patron.exec(texto);\n    if (!m) continue;\n    const valor = r.fijo || numero(m[1].toLowerCase()) * r.factor;\n    if (!mejor || m.index < mejor.pos) mejor = { pos: m.index, valor };\n  }\n  return mejor ? mejor.valor : 1;\n}\n\nfunction detectarZona(texto) {\n  for (const z of ZONAS) {\n    const m = re(z.patron).exec(texto);\n    if (m) return { clave: z.clave, precio: z.precio, lugar: z.lugar || m[0].replace(/\\b\\p{L}/gu, (c) => c.toUpperCase()).replace(/ De | Del /g, (s) => s.toLowerCase()) };\n  }\n  return { clave: 'tienda', precio: 0, lugar: NEGOCIO.base, recogida: true };\n}\n\nfunction detectarUrgencia(texto) {\n  if (re(URGENCIA.alta).test(texto)) return 'alta';\n  if (re(URGENCIA.media).test(texto)) return 'media';\n  return 'normal';\n}\n\nfunction consultarDisponibilidad(pedido) {\n  const porClave = Object.fromEntries(CATALOGO.map((i) => [i.clave, i]));\n  const usados = {};\n  const lineas = [];\n  const faltas = [];\n  for (const { clave, cantidad } of pedido) {\n    const item = porClave[clave];\n    const libres = item.stock - (usados[clave] || 0);\n    const servidas = Math.min(cantidad, libres);\n    usados[clave] = (usados[clave] || 0) + servidas;\n    lineas.push({ clave, cantidad: servidas, pedidas: cantidad });\n    if (servidas < cantidad) {\n      const falta = cantidad - servidas;\n      const alt = item.alternativa && porClave[item.alternativa];\n      const altLibres = alt ? alt.stock - (usados[alt.clave] || 0) : 0;\n      if (alt && altLibres >= falta) {\n        usados[alt.clave] = (usados[alt.clave] || 0) + falta;\n        lineas.push({ clave: alt.clave, cantidad: falta, sustituye: clave });\n        faltas.push({ clave, libres: servidas, alternativa: alt.clave, cantidad: falta });\n      } else {\n        faltas.push({ clave, libres: servidas, alternativa: null, cantidad: falta });\n      }\n    }\n  }\n  return { lineas: lineas.filter((l) => l.cantidad > 0), faltas };\n}\n\nfunction calcularPresupuesto(lineas, dias, zona, urgencia, mascota) {\n  const porClave = Object.fromEntries(CATALOGO.map((i) => [i.clave, i]));\n  // Primero lo que se alquila y después los extras, aunque el cliente los\n  // nombre en otro orden.\n  const orden = [...lineas].sort((a, b) => (porClave[a.clave].extra ? 1 : 0) - (porClave[b.clave].extra ? 1 : 0));\n  const conceptos = orden.map((l) => ({ clave: l.clave, cantidad: l.cantidad, importe: l.cantidad * porClave[l.clave].precioDia * dias }));\n  const subtotal = conceptos.reduce((s, c) => s + c.importe, 0);\n  const pct = dias >= 7 ? 20 : dias >= 3 ? 10 : 0;\n  const descuento = Math.round(subtotal * pct) / 100;\n  const entrega = zona.recogida ? 0 : zona.precio;\n  const express = urgencia === 'alta' ? 10 : 0;\n  const limpieza = mascota && lineas.some((l) => porClave[l.clave].motor) ? 25 : 0;\n  const total = subtotal - descuento + entrega + express + limpieza;\n  return { conceptos, subtotal, pct, descuento, entrega, express, limpieza, total };\n}\n\n// --- respuesta en el idioma del cliente -----------------------------------\n\nconst PLANTILLAS = {\n  es: {\n    hola: (n) => `Hola, ${n}. Gracias por escribir a ${NEGOCIO.nombre}.`,\n    intro: (d, l) => `Te paso el presupuesto para ${d} ${d === 1 ? 'día' : 'días'} (${l}):`,\n    descuento: (d, p) => `Descuento por ${d} días (−${p} %)`, entrega: (l) => `Entrega en ${l === 'aeropuerto' ? 'el aeropuerto' : l}`, recogida: 'Recogida en la tienda de Corralejo', gratis: 'gratis',\n    express: 'Entrega urgente (menos de 1 hora)', limpieza: 'Limpieza por mascota', total: (t) => `Total: ${t}, IGIC incluido.`,\n    mascota: 'Tu mascota es bienvenida.',\n    falta: (d, n, q, a) => `Ahora mismo solo nos quedan ${d} ${n}; en su lugar te ponemos ${q} ${a}.`,\n    faltaSin: (d, n) => `Solo nos quedan ${d} ${n}; un compañero mira si podemos conseguir más.`,\n    dunas: 'Ojo: está prohibido circular por las dunas del Parque Natural de Corralejo.',\n    lobos: 'A la isla de Lobos se va en barco desde Corralejo; las bicis se quedan en tierra.',\n    persona: 'Un compañero te escribe en unos minutos para confirmar un detalle.',\n    cierre: 'Responde «SÍ» y lo dejamos reservado.',\n  },\n  en: {\n    hola: (n) => `Hi ${n}, thanks for getting in touch with ${NEGOCIO.nombre}!`,\n    intro: (d, l) => `Here's your quote for ${d} ${d === 1 ? 'day' : 'days'} (${l}):`,\n    descuento: (d, p) => `${d}-day discount (−${p}%)`, entrega: (l) => `Delivery to ${l === 'airport' ? 'the airport' : l}`, recogida: 'Pick-up at our Corralejo shop', gratis: 'free',\n    express: 'Express delivery (under 1 hour)', limpieza: 'Pet cleaning fee', total: (t) => `Total: ${t}, local tax (IGIC) included.`,\n    mascota: 'Your pet is very welcome.',\n    falta: (d, n, q, a) => `We only have ${d} ${n} left, so we've added ${q} ${a} instead.`,\n    faltaSin: (d, n) => `We only have ${d} ${n} left; a colleague will check if we can get more.`,\n    dunas: 'Heads-up: driving on the dunes of Corralejo Natural Park is not allowed.',\n    lobos: 'Lobos island is reached by boat from Corralejo; the bikes stay on land.',\n    persona: 'A colleague will message you in a few minutes to confirm one detail.',\n    cierre: 'Reply \"YES\" and it\\'s booked.',\n  },\n  de: {\n    hola: (n) => `Hallo ${n}, danke für Ihre Nachricht an ${NEGOCIO.nombre}!`,\n    intro: (d, l) => `Hier ist Ihr Angebot für ${d} ${d === 1 ? 'Tag' : 'Tage'} (${l}):`,\n    descuento: (d, p) => `Rabatt für ${d} Tage (−${p} %)`, entrega: (l) => `Lieferung: ${l}`, recogida: 'Abholung in unserem Laden in Corralejo', gratis: 'kostenlos',\n    express: 'Expresslieferung (unter 1 Stunde)', limpieza: 'Reinigung wegen Haustier', total: (t) => `Gesamt: ${t}, inkl. IGIC (kanarische Steuer).`,\n    mascota: 'Ihr Haustier ist herzlich willkommen.',\n    falta: (d, n, q, a) => `Wir haben nur noch ${d} ${n} frei, deshalb haben wir stattdessen ${q} ${a} eingeplant.`,\n    faltaSin: (d, n) => `Wir haben nur noch ${d} ${n}; ein Kollege prüft, ob wir mehr bekommen.`,\n    dunas: 'Hinweis: Das Fahren auf den Dünen im Naturpark Corralejo ist verboten.',\n    lobos: 'Auf die Insel Lobos geht es mit dem Boot ab Corralejo; die Räder bleiben an Land.',\n    persona: 'Ein Kollege meldet sich in wenigen Minuten, um ein Detail zu bestätigen.',\n    cierre: 'Antworten Sie mit „JA“ und wir reservieren.',\n  },\n  fr: {\n    hola: (n) => `Bonjour ${n}, merci d'avoir écrit à ${NEGOCIO.nombre} !`,\n    intro: (d, l) => `Voici votre devis pour ${d} ${d === 1 ? 'jour' : 'jours'} (${l}) :`,\n    descuento: (d, p) => `Remise ${d} jours (−${p} %)`, entrega: (l) => `Livraison : ${l}`, recogida: 'Retrait à notre boutique de Corralejo', gratis: 'offerte',\n    express: \"Livraison express (moins d'1 heure)\", limpieza: 'Nettoyage animal', total: (t) => `Total : ${t}, IGIC inclus.`,\n    mascota: 'Votre animal est le bienvenu.',\n    falta: (d, n, q, a) => `Il ne nous reste que ${d} ${n} ; nous avons ajouté ${q} ${a} à la place.`,\n    faltaSin: (d, n) => `Il ne nous reste que ${d} ${n} ; un collègue vérifie si nous pouvons en trouver d'autres.`,\n    dunas: 'Attention : il est interdit de rouler sur les dunes du parc naturel de Corralejo.',\n    lobos: \"L'île de Lobos se rejoint en bateau depuis Corralejo ; les vélos restent à terre.\",\n    persona: 'Un collègue vous écrit dans quelques minutes pour confirmer un détail.',\n    cierre: 'Répondez « OUI » et c\\'est réservé.',\n  },\n  it: {\n    hola: (n) => `Ciao ${n}, grazie per aver scritto a ${NEGOCIO.nombre}!`,\n    intro: (d, l) => `Ecco il preventivo per ${d} ${d === 1 ? 'giorno' : 'giorni'} (${l}):`,\n    descuento: (d, p) => `Sconto ${d} giorni (−${p}%)`, entrega: (l) => `Consegna: ${l}`, recogida: 'Ritiro nel nostro negozio di Corralejo', gratis: 'gratuita',\n    express: 'Consegna express (meno di 1 ora)', limpieza: 'Pulizia per animale', total: (t) => `Totale: ${t}, IGIC incluso.`,\n    mascota: 'Il tuo animale è il benvenuto.',\n    falta: (d, n, q, a) => `Ci restano solo ${d} ${n}, quindi abbiamo aggiunto ${q} ${a}.`,\n    faltaSin: (d, n) => `Ci restano solo ${d} ${n}; un collega verifica se possiamo trovarne altri.`,\n    dunas: 'Attenzione: è vietato guidare sulle dune del Parco Naturale di Corralejo.',\n    lobos: \"All'isola di Lobos si va in barca da Corralejo; le bici restano a terra.\",\n    persona: 'Un collega ti scrive tra pochi minuti per confermare un dettaglio.',\n    cierre: 'Rispondi «SÌ» e lo prenotiamo.',\n  },\n  nl: {\n    hola: (n) => `Hoi ${n}, bedankt voor je bericht aan ${NEGOCIO.nombre}!`,\n    intro: (d, l) => `Hier is je offerte voor ${d} ${d === 1 ? 'dag' : 'dagen'} (${l}):`,\n    descuento: (d, p) => `Korting ${d} dagen (−${p}%)`, entrega: (l) => `Bezorging: ${l}`, recogida: 'Ophalen in onze winkel in Corralejo', gratis: 'gratis',\n    express: 'Spoedlevering (binnen 1 uur)', limpieza: 'Schoonmaak huisdier', total: (t) => `Totaal: ${t}, inclusief IGIC.`,\n    mascota: 'Je huisdier is van harte welkom.',\n    falta: (d, n, q, a) => `We hebben nog maar ${d} ${n} vrij, dus we hebben ${q} ${a} toegevoegd.`,\n    faltaSin: (d, n) => `We hebben nog maar ${d} ${n}; een collega kijkt of we er meer kunnen regelen.`,\n    dunas: 'Let op: rijden op de duinen van het Natuurpark Corralejo is verboden.',\n    lobos: 'Naar het eiland Lobos ga je met de boot vanuit Corralejo; de fietsen blijven aan land.',\n    persona: 'Een collega stuurt je over een paar minuten een bericht om één detail te bevestigen.',\n    cierre: 'Antwoord met \"JA\" en het is gereserveerd.',\n  },\n};\n\nfunction nombreItem(clave, n, idioma) {\n  const item = CATALOGO.find((i) => i.clave === clave);\n  return item.nombres[idioma][n === 1 ? 0 : 1];\n}\n\nfunction lugarEn(zona, idioma) {\n  return typeof zona.lugar === 'object' ? zona.lugar[idioma] : zona.lugar;\n}\n\nfunction redactar(idioma, datos) {\n  const T = PLANTILLAS[idioma] || PLANTILLAS.en;\n  const { nombre, dias, zona, presupuesto: p, faltas, avisos } = datos;\n  const lugar = lugarEn(zona, idioma);\n  const r = [T.hola(nombre), '', T.intro(dias, lugar)];\n  for (const c of p.conceptos) r.push(`· ${c.cantidad} ${nombreItem(c.clave, c.cantidad, idioma)} — ${euros(c.importe, idioma)}`);\n  if (p.pct) r.push(`· ${T.descuento(dias, p.pct)} — −${euros(p.descuento, idioma)}`);\n  r.push(zona.recogida ? `· ${T.recogida}` : `· ${T.entrega(lugar)} — ${p.entrega ? euros(p.entrega, idioma) : T.gratis}`);\n  if (p.express) r.push(`· ${T.express} — ${euros(p.express, idioma)}`);\n  if (p.limpieza) r.push(`· ${T.limpieza} — ${euros(p.limpieza, idioma)}`);\n  r.push(T.total(euros(p.total, idioma)));\n  const notas = [];\n  if (avisos.mascota) notas.push(T.mascota);\n  for (const f of faltas) {\n    notas.push(f.alternativa\n      ? T.falta(f.libres, nombreItem(f.clave, f.libres, idioma), f.cantidad, nombreItem(f.alternativa, f.cantidad, idioma))\n      : T.faltaSin(f.libres, nombreItem(f.clave, f.libres, idioma)));\n  }\n  for (const regla of avisos.reglas) notas.push(T[regla]);\n  if (avisos.revisar.length) notas.push(T.persona);\n  if (notas.length) r.push('', ...notas);\n  r.push('', T.cierre, `${NEGOCIO.nombre} · ${NEGOCIO.base}`);\n  return r.join('\\n');\n}\n\n// Minutos que le lleva a una persona cada paso, a ojo de quien lo ha hecho:\n// leer y entender un mensaje en otro idioma, mirar el stock, sumar, escribir\n// la respuesta en ese idioma y apuntar el contacto.\nfunction minutosHumanos(idioma, lineas) {\n  const extranjero = idioma !== 'es';\n  const d = [\n    { paso: 'Entender el idioma', min: extranjero ? 1 : 0 },\n    { paso: 'Traducir el mensaje', min: extranjero ? 3 : 0 },\n    { paso: 'Identificar el pedido', min: 1 + lineas },\n    { paso: 'Consultar disponibilidad', min: 2 * lineas },\n    { paso: 'Calcular el presupuesto', min: 3 },\n    { paso: 'Redactar la respuesta', min: extranjero ? 7 : 4 },\n    { paso: 'Apuntar el contacto', min: 2 },\n  ].filter((x) => x.min > 0);\n  return { desglose: d, total: d.reduce((s, x) => s + x.min, 0) };\n}\n\nfunction procesar({ texto, nombre }) {\n  const idioma = detectarIdioma(texto);\n  const pedido = identificarPedido(texto);\n  const dias = detectarDias(texto);\n  const zona = detectarZona(texto);\n  const urgencia = detectarUrgencia(texto);\n\n  const hay = (k) => re(AVISOS[k]).test(texto);\n  const avisos = {\n    mascota: hay('mascota'),\n    reglas: ['dunas', 'lobos'].filter(hay),\n    revisar: ['licencia', 'principiante', 'especial'].filter(hay).map((k) => REVISAR[k]),\n  };\n\n  const disp = consultarDisponibilidad(pedido);\n  for (const f of disp.faltas) {\n    if (!f.alternativa) avisos.revisar.push(`${f.cantidad === 1 ? 'Falta' : 'Faltan'} ${f.cantidad} ${nombreItem(f.clave, f.cantidad, 'es')}: buscar más o proponer otra cosa`);\n  }\n  const presupuesto = calcularPresupuesto(disp.lineas, dias, zona, urgencia, avisos.mascota);\n  const datos = { nombre, dias, zona, presupuesto, faltas: disp.faltas, avisos };\n\n  return {\n    idioma,\n    pedido: pedido.map((l) => ({ ...l, nombre: nombreItem(l.clave, l.cantidad, 'es') })),\n    dias,\n    zona: { clave: zona.clave, lugar: lugarEn(zona, 'es'), recogida: !!zona.recogida },\n    urgencia,\n    disponibilidad: {\n      completa: disp.faltas.length === 0,\n      lineas: disp.lineas.map((l) => ({ ...l, nombre: nombreItem(l.clave, l.cantidad, 'es') })),\n      faltas: disp.faltas.map((f) => ({ ...f, nombre: nombreItem(f.clave, f.libres, 'es'), alternativaNombre: f.alternativa ? nombreItem(f.alternativa, f.cantidad, 'es') : null })),\n    },\n    presupuesto,\n    avisos,\n    necesitaPersona: avisos.revisar.length > 0,\n    respuesta: redactar(idioma.codigo, datos),\n    respuestaEs: idioma.codigo === 'es' ? null : redactar('es', datos),\n    minutos: minutosHumanos(idioma.codigo, disp.lineas.length),\n  };\n}\n\n// --- Entrada del webhook --------------------------------------------------\n// Espera {\"nombre\": \"...\", \"texto\": \"...\", \"contacto\": \"...\"} en el cuerpo.\nconst cuerpo = $json.body || $json;\nconst texto = String(cuerpo.texto || cuerpo.mensaje || '').slice(0, 2000);\nconst nombre = String(cuerpo.nombre || 'cliente').slice(0, 60);\nif (!texto.trim()) return { json: { ok: false, error: 'Falta el texto del mensaje' } };\n\nreturn {\n  json: {\n    ok: true,\n    recibido: new Date().toISOString(),\n    nombre,\n    contacto: String(cuerpo.contacto || '').slice(0, 120),\n    texto,\n    ...procesar({ texto, nombre }),\n  },\n};\n"
      },
      "id": "08ada48d-7026-5ee1-a9e1-37f5800f1361"
    },
    {
      "name": "Apuntar en la hoja",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        480,
        300
      ],
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "https://docs.google.com/spreadsheets/d/TU_HOJA/edit"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Contactos"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Fecha": "={{ $json.recibido }}",
            "Nombre": "={{ $json.nombre }}",
            "Contacto": "={{ $json.contacto }}",
            "Idioma": "={{ $json.idioma.nombre }}",
            "Pedido": "={{ $json.pedido.map(l => l.cantidad + \" \" + l.nombre).join(\", \") }}",
            "Días": "={{ $json.dias }}",
            "Zona": "={{ $json.zona.lugar }}",
            "Urgencia": "={{ $json.urgencia }}",
            "Total": "={{ $json.presupuesto.total }}",
            "Necesita persona": "={{ $json.necesitaPersona ? \"Sí\" : \"No\" }}",
            "Mensaje": "={{ $json.texto }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "Fecha",
              "displayName": "Fecha",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Nombre",
              "displayName": "Nombre",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Contacto",
              "displayName": "Contacto",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Idioma",
              "displayName": "Idioma",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Pedido",
              "displayName": "Pedido",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Días",
              "displayName": "Días",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Zona",
              "displayName": "Zona",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Urgencia",
              "displayName": "Urgencia",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Total",
              "displayName": "Total",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Necesita persona",
              "displayName": "Necesita persona",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Mensaje",
              "displayName": "Mensaje",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "b69b0205-8e38-554d-a296-880426a84365"
    },
    {
      "name": "¿Hace falta una persona?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        720,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ef330610-83c6-562c-ad23-4c698ed6204c",
              "leftValue": "={{ $('Procesar mensaje').item.json.necesitaPersona }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "bb4a66b0-53de-587c-ad66-ac3fffd22995"
    },
    {
      "name": "Avisar por Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        960,
        200
      ],
      "parameters": {
        "chatId": "TU_CHAT_ID",
        "text": "={{ \"Cliente para revisar: \" + $('Procesar mensaje').item.json.nombre + \" (\" + $('Procesar mensaje').item.json.idioma.nombre + \")\\n\\n\" + $('Procesar mensaje').item.json.avisos.revisar.join(\"\\n\") + \"\\n\\nMensaje:\\n\" + $('Procesar mensaje').item.json.texto + \"\\n\\nRespuesta preparada:\\n\" + $('Procesar mensaje').item.json.respuesta }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e4ee25a3-288e-58f8-a963-38723d83f395"
    },
    {
      "name": "Responder",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1200,
        300
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: $('Procesar mensaje').item.json.ok, idioma: $('Procesar mensaje').item.json.idioma, total: $('Procesar mensaje').item.json.presupuesto && $('Procesar mensaje').item.json.presupuesto.total, necesitaPersona: $('Procesar mensaje').item.json.necesitaPersona, respuesta: $('Procesar mensaje').item.json.respuesta }) }}",
        "options": {}
      },
      "id": "810a867b-0793-5b25-afee-82fc76f3f712"
    },
    {
      "name": "Versión de muestra",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -140
      ],
      "parameters": {
        "width": 720,
        "height": 380,
        "color": 5,
        "content": "## Cliente Salvaje · versión de muestra\nEl flujo de la demostración de albydev.com/cliente-salvaje.html. Recibe el mensaje de un cliente de un alquiler (ficticio) de Corralejo y saca idioma, pedido, días, zona, urgencia, disponibilidad y presupuesto. Redacta la respuesta en su idioma (español, inglés, alemán, francés, italiano o neerlandés), la apunta en una hoja y, si hace falta una persona, avisa por Telegram.\n\n**Para probarlo:** pon tu hoja en «Apuntar en la hoja» (pestaña Contactos), tu bot y tu chat en «Avisar por Telegram», y manda un POST al webhook con `{\"nombre\": \"Klaus\", \"texto\": \"...\"}`.\n\n**No incluye:** entender mensajes libres con IA (aquí son reglas y listas de palabras, pensadas para los escenarios de la web), tu stock real, ni la conexión con WhatsApp o tu correo. Eso va en la versión montada: hola@albydev.com"
      },
      "id": "2227911a-d147-51fb-a0c8-17f91b71470c"
    }
  ],
  "connections": {
    "Mensaje de un cliente": {
      "main": [
        [
          {
            "node": "Procesar mensaje",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Procesar mensaje": {
      "main": [
        [
          {
            "node": "Apuntar en la hoja",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apuntar en la hoja": {
      "main": [
        [
          {
            "node": "¿Hace falta una persona?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "¿Hace falta una persona?": {
      "main": [
        [
          {
            "node": "Avisar por Telegram",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Responder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Avisar por Telegram": {
      "main": [
        [
          {
            "node": "Responder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Atlantic/Canary"
  },
  "pinData": {},
  "active": false,
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
