Selaa lähdekoodia

initial commit

Masterstreet 11 kuukautta sitten
sitoutus
6ee6dccdf7

+ 4 - 0
.env

@@ -0,0 +1,4 @@
+PORT = 6001
+SECRET_KEY = 866B3F5EE90BFED7EDAD0FCB0A9C0FC866F03166C05648478ECEF6148C9E13BEBF8E429BB9B06DCF
+OPENAI_API_KEY = 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
+NODE_ENV = development

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+.vscode
+pedidos_express.zip
+tailwind.config.js

+ 78 - 0
data.json

@@ -0,0 +1,78 @@
+[
+    {
+      "pregunta": "¿Qué es Klein?",
+      "respuesta": "Klein, Cervecería Klein o 'el Klein' se refieren al Biergarten Klein."
+    },
+    {
+      "pregunta": "¿Dónde puedo ver el menú de Klein?",
+      "respuesta": "Puedes consultar la carta en https://menu.fu.do/klein/qr-menu."
+    },
+    {
+      "pregunta": "¿Qué cervezas tienen en Klein?",
+      "respuesta": "Ofrecemos cervezas artesanales de la casa como Hoppy Mosh (IPA), Black Mamba (Porter), Soviet Riot (Russian Imperial Stout), Queen Burlesque (American Strong Ale), 24K Gold (Golden Ale) y Bendición Gitana (Pale Ale)."
+    },
+    {
+      "pregunta": "¿Qué comida recomiendan con la Hoppy Mosh?",
+      "respuesta": "Prueba esta IPA con nuestro ceviche, te sorprenderá la combinación refrescante."
+    },
+    {
+      "pregunta": "¿Qué maridaje tiene la Black Mamba?",
+      "respuesta": "Combínala con la Capresse 3 Quesos o la Cuatro Quesos para una experiencia deliciosa."
+    },
+    {
+      "pregunta": "¿Con qué plato va bien la Soviet Riot?",
+      "respuesta": "Es excelente para acompañar la Mechaloca, nuestra hamburguesa de carne mechada con queso cheddar y tocino."
+    },
+    {
+      "pregunta": "¿Qué cerveza es ideal para la Rodeo Burger?",
+      "respuesta": "Queen Burlesque es perfecta para disfrutar con la Rodeo Burger, sus sabores a caramelo y pasas complementan los toques ahumados de la salsa BBQ."
+    },
+    {
+      "pregunta": "¿Qué bebida es ideal para el verano?",
+      "respuesta": "24K Gold, una Golden Ale suave y sedosa con notas cítricas, ideal para días calurosos."
+    },
+    {
+      "pregunta": "¿Qué cerveza recomiendan con pizza vegetariana?",
+      "respuesta": "Bendición Gitana, una Pale Ale ligera que se lleva bien con la Vegetariana o la Doble Pepperoni."
+    },
+    {
+      "pregunta": "¿Qué opciones de gin tienen?",
+      "respuesta": "Tenemos opciones como Summer Klein, Tropical Bliss, Spicy Mango Xawer y Kalfuko, además de nuestra Tónica pomelo de la casa."
+    },
+    {
+      "pregunta": "¿Qué lleva el cóctel Summer Klein?",
+      "respuesta": "Refrescante mezcla de gin propio, jugo de naranja, jugo de limón y nuestra ginger beer de la casa."
+    },
+    {
+      "pregunta": "¿Qué hamburguesas tienen en Klein?",
+      "respuesta": "Ofrecemos la Rodeo Burger, Cheese Burger, Burger Play, Play Harder, Mechaloca y Veggie Marley."
+    },
+    {
+      "pregunta": "¿Cuál es la hamburguesa más jugosa?",
+      "respuesta": "Mechaloca, con carne mechada, queso cheddar, tocino, cebolla caramelizada, pepinillos y salsa ahumada."
+    },
+    {
+      "pregunta": "¿Tienen hamburguesas vegetarianas?",
+      "respuesta": "Sí, tenemos la Veggie Marley con pan pita, seitán, tomate, lechuga, coleslaw, cebolla morada y salsa ali oli. También ofrecemos la Not Burger como alternativa en nuestras hamburguesas clásicas."
+    },
+    {
+      "pregunta": "¿Cuáles son sus pizzas?",
+      "respuesta": "Tenemos varias opciones como Campestre, Cuatro Quesos, Pepperoni, Bianca, Piacere, Camarón al ajillo, Napolitana, Vegetariana, Doble Pepperoni, Margarita y Pollo BBQ."
+    },
+    {
+      "pregunta": "¿Cuál es su pizza más popular?",
+      "respuesta": "La Cuatro Quesos, con queso mozzarella, camembert, azul y edam, es una de las favoritas."
+    },
+    {
+      "pregunta": "¿Tienen pizzas con carne mechada?",
+      "respuesta": "Sí, la Piacere y la Campestre llevan carne mechada."
+    },
+    {
+      "pregunta": "¿Qué pizza lleva camarón?",
+      "respuesta": "La Camarón al ajillo, con camarón salteado, champiñón, cebolla morada y queso mozzarella."
+    },
+    {
+      "pregunta": "¿Qué tragos con gin tienen?",
+      "respuesta": "Puedes probar Tropical Bliss, Spicy Mango Xawer y Kalfuko, además de nuestra Tónica pomelo de la casa."
+    }
+  ]

+ 0 - 0
impresora/__init__.py


BIN
impresora/__pycache__/__init__.cpython-313.pyc


+ 0 - 0
impresora/__pycache__/__init__.py


BIN
impresora/__pycache__/order.cpython-312.pyc


BIN
impresora/__pycache__/order.cpython-313.pyc


BIN
impresora/__pycache__/printer.cpython-312.pyc


BIN
impresora/__pycache__/printer.cpython-313.pyc


BIN
impresora/__pycache__/scrapper.cpython-312.pyc


+ 151 - 0
impresora/chat.py

@@ -0,0 +1,151 @@
+from ast import arg
+import openai
+from printer import Printer
+from order import Order, Item
+import threading
+from scrapper import scrap_menu
+
+# Reemplaza 'TU_API_KEY' con tu clave real de OpenAI
+openai.api_key = "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"
+lista = []
+
+def nueva_lista():
+    """Elimina la lista de compras."""
+    print("Lista de compras eliminada.")
+    lista.clear()
+
+def add_to_list(*items):
+    """Agrega uno o más items a la lista de compras."""
+    for item in items:
+        if isinstance(item, dict) and "name" in item and "quantity" in item:
+            if item not in lista:
+                lista.append(item)
+            else:
+                item["quantity"] += item["quantity"]
+    
+
+def buy():
+    """Envía la lista de compra."""
+    if not lista:
+        print("No hay items en la lista de compras.")
+        return
+    order = Order([Item(item["name"], 10, item["quantity"]) for item in lista])
+    printer = Printer()
+    # Lanzar impresión en segundo plano y esperar a que termine
+    thread = threading.Thread(target=printer.print_order, args=(order,))
+    thread.start()
+    thread.join()
+    print("Lista de compra enviada.")
+    nueva_lista()
+
+function_definitions = [
+    {
+        "name": "add_to_list",
+        "description": "Agrega items nuevos a la lista de compras",
+        "parameters": {
+            "type": "object",
+            "properties": {
+                "items": {
+                    "type": "array",
+                    "items": {
+                        "type": "object",
+                        "properties": {
+                            "name": {
+                                "type": "string",
+                                "description": "Nombre del producto"
+                            },
+                            "quantity": {
+                                "type": "integer",
+                                "description": "Cantidad del producto"
+                            }
+                        },
+                        "required": ["name", "quantity"]
+                    },
+                    "description": "Lista de items a agregar"
+                }
+            },
+            "required": ["items"]
+        }
+    },
+    {
+        "name": "buy",
+        "description": "Envía la lista de compra",
+        "parameters": {
+            "type": "object",
+            "properties": {}
+        }
+    }
+]
+
+def handle_function_call(name, arguments):
+    if name == "nueva_lista":
+        nueva_lista()
+        return "e vaciado la lista de compras. ¿Deseas agregar un producto?"
+    elif name == "add_to_list":
+        add_to_list(*arguments.get("items", []))
+        print(arguments)
+        return f'Claro eh agregado {", ".join([f"{item["quantity"]} {item["name"]}{"s" if item["quantity"] > 1 else ""}" for item in arguments["items"]])} a la lista de compras. ¿Deseas agregar otro producto?'
+    elif name == "buy":
+        buy()
+        return "Tu pedido llegará en un momento"
+    else:
+        return "Función no reconocida."
+
+def main():
+    print("Chat GPT-4o-mini. Escribe 'salir' para terminar.")
+    messages = [
+        {"role": "system", "content": "Eres un asistente de ventas, tu nombre sera 'Camilo Klein' amigable y profesional tu misión es ayudar a los clientes presentandoles tu cerveceria con sus compras cuando agregues un producto a la lista de compras confirma diciendo por ejemplo '1 [nombre del producto] agregado' y siempre pregunta si desean añadir algo más si dicen que no pregúntales si quieren enviar la lista de compras recuerda que al usar la función 'add to list' solo debes agregar el producto pedido, ignorando la lista de compras"},
+        {"role": "system", "content": """solo puedes responder preguntas de o relacionadas a la siguiente tabla
+         
+         Pregunta	Respuesta
+¿Qué es Klein?	Klein, Cervecería Klein o 'el Klein' se refieren al Biergarten Klein.
+¿Dónde puedo ver el menú de Klein?	Puedes consultar la carta en https://menu.fu.do/klein/qr-menu.
+¿Qué cervezas tienen en Klein?	Ofrecemos cervezas artesanales de la casa como Hoppy Mosh (IPA), Black Mamba (Porter), Soviet Riot (Russian Imperial Stout), Queen Burlesque (American Strong Ale), 24K Gold (Golden Ale) y Bendición Gitana (Pale Ale).
+¿Qué comida recomiendan con la Hoppy Mosh?	Prueba esta IPA con nuestro ceviche, te sorprenderá la combinación refrescante.
+¿Qué maridaje tiene la Black Mamba?	Combínala con la Capresse 3 Quesos o la Cuatro Quesos para una experiencia deliciosa.
+¿Con qué plato va bien la Soviet Riot?	Es excelente para acompañar la Mechaloca, nuestra hamburguesa de carne mechada con queso cheddar y tocino.
+¿Qué cerveza es ideal para la Rodeo Burger?	Queen Burlesque es perfecta para disfrutar con la Rodeo Burger, sus sabores a caramelo y pasas complementan los toques ahumados de la salsa BBQ.
+¿Qué bebida es ideal para el verano?	24K Gold, una Golden Ale suave y sedosa con notas cítricas, ideal para días calurosos.
+¿Qué cerveza recomiendan con pizza vegetariana?	Bendición Gitana, una Pale Ale ligera que se lleva bien con la Vegetariana o la Doble Pepperoni.
+¿Qué opciones de gin tienen?	Tenemos opciones como Summer Klein, Tropical Bliss, Spicy Mango Xawer y Kalfuko, además de nuestra Tónica pomelo de la casa.
+¿Qué lleva el cóctel Summer Klein?	Refrescante mezcla de gin propio, jugo de naranja, jugo de limón y nuestra ginger beer de la casa.
+¿Qué hamburguesas tienen en Klein?	Ofrecemos la Rodeo Burger, Cheese Burger, Burger Play, Play Harder, Mechaloca y Veggie Marley.
+¿Cuál es la hamburguesa más jugosa?	Mechaloca, con carne mechada, queso cheddar, tocino, cebolla caramelizada, pepinillos y salsa ahumada.
+¿Tienen hamburguesas vegetarianas?	Sí, tenemos la Veggie Marley con pan pita, seitán, tomate, lechuga, coleslaw, cebolla morada y salsa ali oli. También ofrecemos la Not Burger como alternativa en nuestras hamburguesas clásicas.
+¿Cuáles son sus pizzas?	Tenemos varias opciones como Campestre, Cuatro Quesos, Pepperoni, Bianca, Piacere, Camarón al ajillo, Napolitana, Vegetariana, Doble Pepperoni, Margarita y Pollo BBQ.
+¿Cuál es su pizza más popular?	La Cuatro Quesos, con queso mozzarella, camembert, azul y edam, es una de las favoritas.
+¿Tienen pizzas con carne mechada?	Sí, la Piacere y la Campestre llevan carne mechada.
+¿Qué pizza lleva camarón?	La Camarón al ajillo, con camarón salteado, champiñón, cebolla morada y queso mozzarella.
+¿Qué tragos con gin tienen?	Puedes probar Tropical Bliss, Spicy Mango Xawer y Kalfuko, además de nuestra Tónica pomelo de la casa."""},
+    {"role":"system", "content":"Lista actual de compras:\n\n{lista}\n"}
+    ]
+    while True:
+        user_input = input("Tú: ")
+        if user_input.lower() == "salir":
+            break
+        messages.append({"role": "user", "content": user_input})
+        response = openai.chat.completions.create(
+            model="gpt-4o-mini",
+            messages=messages,
+            functions=function_definitions,
+            function_call="auto",
+            temperature=0.3
+        )
+        reply_msg = response.choices[0].message
+        if reply_msg.function_call:
+            func_name = reply_msg.function_call.name
+            import json
+            arguments = json.loads(reply_msg.function_call.arguments)
+            result = handle_function_call(func_name, arguments)
+            print("Asistente:", result)
+            messages.append({
+                "role": "assistant",
+                "content": result
+            })
+        else:
+            reply = reply_msg.content.strip()
+            print("Asistente:", reply)
+            messages.append({"role": "assistant", "content": reply})
+
+if __name__ == "__main__":
+    main()

+ 26 - 0
impresora/order.py

@@ -0,0 +1,26 @@
+import datetime
+import tabulate
+
+class Item:
+    def __init__(self, name, price, quantity):
+        self.name = name
+        self.price = price
+        self.quantity = quantity
+
+class Order:
+
+    def __init__(self, items):
+        self.user: str
+        self.items:list[Item] = items
+        self.total = sum(item.price * item.quantity for item in items)
+        self.date = datetime.datetime.now()
+
+
+    def tabulate(self):
+        headers = ["Nombre", "Cantidad", "Total"]
+        data = [[item.name, item.quantity, item.price*item.quantity] for item in self.items]
+        return tabulate.tabulate(data, headers=headers, tablefmt="fancy_grid")
+    
+    def __str__(self):
+        return f"Orden de {self.date.strftime('%d/%m/%Y')}\n{self.items}\nTotal: {self.total}"
+

+ 76 - 0
impresora/printer.py

@@ -0,0 +1,76 @@
+import tabulate
+from escpos.printer.win32raw import Win32Raw
+from escpos.printer.usb import Usb
+from escpos.printer.network import Network
+from escpos.printer import Dummy
+from escpos.escpos import Escpos
+from impresora.order import Order
+
+
+class BasePrinter:
+
+    def __init__(self):
+        self.bolded = False
+        self.font = "a"
+        self.doubled_size = False
+        self.work = Dummy()
+        self.printer:Escpos
+    def change_font(self):
+        self.font = "b" if self.font == "a" else "a"
+        self.work.set(font=self.font)
+    def text(self, text):
+        # Print the text
+        self.work.text(text+"\n")
+    def bold(self):
+        # Set bold for the next print
+        self.bolded = not self.bolded
+        self.work.set(bold=self.bolded)
+    def double_size(self):
+        # Set double size for the next print
+        self.doubled_size = not self.doubled_size
+        self.work.set(double_height=self.doubled_size, double_width=self.doubled_size)
+        
+    def default(self):
+        # Set default size for the next print
+        self.work.set_with_default()
+        
+    def print_order(self, order:Order, mesa:int = 0):
+    # Print the order details
+        self.default()
+        self.double_size()
+        self.bold()
+
+        self.text(f"Orden Biergarten Klein\n")
+        self.default()
+        self.text(f"Fecha: {order.date.strftime('%d/%m/%Y: %H:%M:%S')}")
+        self.bold()
+        self.text(f"Mesa: {mesa}\n")
+        self.default()
+        self.text(order.tabulate())
+        self.bold()
+        self.text(f"Total: {order.total}")
+        
+        self.work.cut()
+
+        # Print the order details
+        self.printer._raw(self.work.output)
+        self.work.clear()
+        # Intentar cerrar la impresora si el método existe
+        if hasattr(self.printer, 'close'):
+            self.printer.close()
+
+
+class PrinterWindows(BasePrinter):
+    def __init__(self, printer_name="Impresora Termica"):
+        super().__init__()
+        self.printer = Win32Raw(printer_name)
+
+class PrinterUSB(BasePrinter):
+    def __init__(self, vendor_id, product_id):
+        super().__init__()
+        self.printer = Usb(vendor_id, product_id,in_ep=0x81,out_ep=0x03)
+
+class PrinterNetwork(BasePrinter):
+    def __init__(self, host, port):
+        super().__init__()
+        self.printer = Network(host, port)

+ 238 - 0
main.py

@@ -0,0 +1,238 @@
+import os
+import json
+import secrets
+from typing import List, Dict, Union, Annotated
+
+from fastapi import FastAPI, Request, HTTPException, Header, Depends
+from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from openai import OpenAI
+from dotenv import load_dotenv
+from starlette.middleware.sessions import SessionMiddleware
+
+from impresora.printer import PrinterUSB
+from impresora.order import *
+
+# Load environment variables from .env file
+load_dotenv()
+
+# Configuration
+OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
+PORT = int(os.getenv("PORT", 6001))
+# SECRET_KEY is crucial for signing session cookies.
+# Fallback to a default if not set, but warn that this is insecure for production.
+SECRET_KEY = os.getenv("SECRET_KEY", "your_very_very_secret_key_for_signing_cookies_python_v2")
+if SECRET_KEY == "your_very_very_secret_key_for_signing_cookies_python_v2":
+    print("WARNING: Using default SECRET_KEY. Please set a strong SECRET_KEY in your .env file for production.")
+
+if not OPENAI_API_KEY:
+    print("CRITICAL ERROR: OPENAI_API_KEY environment variable not set. The applicaton will not work correctly.")
+    # Potentially exit or prevent app startup if critical env var is missing
+    # raise ValueError("OPENAI_API_KEY is not set, cannot start application.")
+
+
+# --- FastAPI App Initialization ---
+app = FastAPI(title="Web Pedidos Klein - FastAPI Backend")
+
+# Add SessionMiddleware
+# This middleware adds session support using signed cookies.
+# Original Express maxAge was 1 hour (60 * 60 * 1000 ms)
+app.add_middleware(
+    SessionMiddleware,
+    secret_key=SECRET_KEY,
+    max_age=60 * 60 # max_age in seconds for Starlette
+)
+
+# --- Data Loading ---
+# Assumes data.json is in the same directory as main.py
+# The original path was web_pedidos/src/data.json
+# For the Python version, copy src/data.json to be alongside main.py
+BG_DATA_PATH = os.path.join(os.path.dirname(__file__), 'data.json')
+PRODUCTS_PATH = os.path.join(os.path.dirname(__file__), 'products.json')
+
+def load_bg_data() -> List[Dict[str, str]]:
+    try:
+        with open(BG_DATA_PATH, 'r', encoding='utf-8') as f:
+            return json.load(f)
+    except FileNotFoundError:
+        print(f"ERROR: Data file not found at {BG_DATA_PATH}. Serving with empty data.")
+        return []
+    except json.JSONDecodeError:
+        print(f"ERROR: Could not decode JSON from {BG_DATA_PATH}. Serving with empty data.")
+        return []
+
+def load_products() -> List[Dict[str, str]]:
+    try:
+        with open(PRODUCTS_PATH, 'r', encoding='utf-8') as f:
+            return json.load(f)
+    except FileNotFoundError:
+        print(f"ERROR: Data file not found at {PRODUCTS_PATH}. Serving with empty data.")
+        return []
+    except json.JSONDecodeError:
+        print(f"ERROR: Could not decode JSON from {PRODUCTS_PATH}. Serving with empty data.")
+        return []
+bg_data_loaded = load_bg_data()
+all_products = load_products()
+# region --- Pydantic Models for Request/Response Typing ---
+class Message(BaseModel):
+    role: str
+    content: str
+
+class ChatCompletionRequest(BaseModel):
+    messages: List[Message]
+
+class ItemWeb(BaseModel):
+    id: int
+    name: str
+    quantity: int
+    price: float
+    itemTotal: float
+
+class OrderWeb(BaseModel):
+    items: List[ItemWeb]
+    totalAmount: float
+    orderDate: str
+    table: int
+# endregion --- Pydantic Models for Request/Response Typing ---
+
+# region --- OpenAI Service Logic ---
+openai_client = OpenAI(api_key=OPENAI_API_KEY)
+
+async def generate_completion(messages_array: List[Message], session_id: str) -> str:
+    if not OPENAI_API_KEY:
+        print("Error: OpenAI API key is not configured.")
+        raise HTTPException(status_code=500, detail="OpenAI API key not configured on server.")
+
+    print(f"[OpenAI Service Python] Session/Token {session_id} sent: {[msg.model_dump() for msg in messages_array]}")
+
+    data_for_prompt = [
+        f'{{"pregunta": "{item.get("pregunta", "")}", "respuesta": "{item.get("respuesta", "")}"}}'
+        for item in bg_data_loaded
+    ]
+    data_string = "\n".join(data_for_prompt)
+
+    preprompt = f"""
+Eres un asistente de el bar klein, tu nombre es camilo klein, usas emojis para responder.
+y ser carismatico con el cliente.
+tus responsabilidades son:
+- Responder preguntas sobre el menu de el bar klein
+- Proporcionar información sobre el menú de el bar klein
+- Proporcionar recomendaciones sobre el menú de el bar klein
+- Proporcionar información sobre la comida de el bar klein
+- No puedes tomar pedidos de clientes, solo informar
+- Debes evadir cualquier pregunta que no sea relacionada con el bar klein
+para esto usaras los siguientes datos:
+{data_string}
+    """ #
+
+    processed_messages: List[Dict[str, str]] = [{"role": "system", "content": preprompt}]
+    processed_messages.extend([msg.model_dump() for msg in messages_array])
+
+
+    try:
+        completion = openai_client.chat.completions.create(
+            model="gpt-4o-mini", #
+            messages=processed_messages, # type: ignore (OpenAI lib expects list of specific dicts)
+            temperature=0.3, #
+        )
+        response_content = completion.choices[0].message.content
+        return response_content if response_content else "-1" #
+    except Exception as e:
+        print(f"Error calling OpenAI: {e}")
+        # Avoid exposing detailed error messages to the client unless necessary
+        raise HTTPException(status_code=500, detail="Error al procesar tu solicitud con OpenAI.")
+
+# endregion --- OpenAI Service Logic ---
+# --- Security/Token Dependency ---
+async def get_session_token(request: Request) -> Union[str, None]:
+    return request.session.get("antiAbuseToken")
+
+async def protect_chat_api(
+    request: Request,
+    x_app_token: Annotated[Union[str, None], Header(alias="X-App-Token")] = None,
+    session_token: Annotated[Union[str, None], Depends(get_session_token)] = None
+):
+    # Equivalent to protectChatAPI middleware
+    if not session_token:
+        raise HTTPException(status_code=403, detail="Acceso denegado: Sesión inválida o token no inicializado.")
+
+    if not x_app_token:
+        raise HTTPException(status_code=401, detail="Acceso denegado: Falta el token X-Chat-Token.")
+
+    if x_app_token != session_token:
+        # Log this attempt for security monitoring
+        print(f"WARN: Invalid token attempt. Expected: {session_token}, Received: {x_app_token}")
+        raise HTTPException(status_code=403, detail="Acceso denegado: Token inválido.")
+    return True # Protection passed
+
+
+@app.get("/api/get_products", summary="Get products")
+async def get_products():
+    return JSONResponse({"products": all_products})
+# --- API Endpoints ---
+@app.get("/api/chat/init-chat", summary="Initialize chat and get anti-abuse token")
+async def init_chat(request: Request):
+    current_token = request.session.get("antiAbuseToken")
+    if not current_token:
+        new_token = secrets.token_hex(32)
+        request.session["antiAbuseToken"] = new_token # Store in session
+        print(f"Generated new antiAbuseToken for session: {new_token}")
+        return JSONResponse({"chatToken": new_token})
+    else:
+        # print(f"Using existing antiAbuseToken for session: {current_token}")
+        return JSONResponse({"chatToken": current_token})
+
+
+@app.post("/api/printer/order", summary="Printer order", dependencies=[Depends(protect_chat_api)])
+async def printer_order(order: OrderWeb):
+    print("Printer order received")
+    items = order.items
+    table = order.table
+    printer = PrinterUSB(0xfe6,0x811e)
+    print_order = Order([Item(item.name, item.price, item.quantity) for item in items])
+    printer.print_order(print_order, table)
+
+
+@app.post("/api/chat/completions",
+          summary="Get chat completions from OpenAI",
+          dependencies=[Depends(protect_chat_api)])
+async def chat_completions(request_data: ChatCompletionRequest, request: Request):
+    # Uses session_token (which is the antiAbuseToken) as an identifier for logging
+    session_identifier = request.session.get("antiAbuseToken", "unknown_session")
+
+    try:
+        openai_response = await generate_completion(request_data.messages, session_identifier)
+        return JSONResponse({"response": openai_response})
+    except HTTPException as e: # Re-raise HTTPExceptions from called functions
+        raise e
+    except Exception as e:
+        print(f"Unexpected error in /api/chat/completions: {e}")
+        raise HTTPException(status_code=500, detail="Error interno del servidor al procesar el chat.")
+
+
+@app.get("/", response_class=HTMLResponse, include_in_schema=False)
+async def serve_index_html():
+    index_path = os.path.join("public", "index.html")
+    if not os.path.exists(index_path):
+        raise HTTPException(status_code=404, detail="public/index.html not found.")
+    return FileResponse(index_path)
+
+app.mount("/", StaticFiles(directory="public", html=False), name="public_root_assets")
+
+# --- Main Application Runner ---
+if __name__ == "__main__":
+    if not OPENAI_API_KEY:
+        print("FATAL: OPENAI_API_KEY is not set. OpenAI features will fail.")
+        print("Please create a .env file with OPENAI_API_KEY='your_key_here'")
+        with open(".env", "w") as f:
+            f.write("OPENAI_API_KEY='your_key_here'")
+    
+    print(f"Servidor corriendo en http://localhost:{PORT}")
+    if not os.path.exists(BG_DATA_PATH):
+         print(f"ADVERTENCIA: {BG_DATA_PATH} no encontrado. El asistente de IA no tendrá datos específicos del menú.")
+    else:
+        print(f"Datos del asistente cargados desde: {os.path.abspath(BG_DATA_PATH)}")
+    
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=PORT)

+ 106 - 0
products.json

@@ -0,0 +1,106 @@
+[
+  {
+    "id": 1,
+    "name": "Witbier",
+    "category": "Cervezas Artesanales",
+    "description": "Cerveza estilo belga, con un 50% de trigo en su receta, además de cascara de naranja dulce y semillas de cilantro. Es una cerveza refrescante con solo 4,5° de alcohol. De aspecto turbio y de color claro.",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Witbier"
+  },
+  {
+    "id": 2,
+    "name": "24K Gold",
+    "category": "Cervezas Artesanales",
+    "description": "Golden Ale - 4,5º - IBU 20 - SRM 4 - Cerveza Ale dorada. En boca sedosa y suave con notas cítricas.",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+24K+Gold"
+  },
+  {
+    "id": 3,
+    "name": "Burlesque",
+    "category": "Cervezas Artesanales",
+    "description": "5.0º - IBU 12 - SRM 16 - Cerveza Ale ámbar maltosa con notas a caramelo y galleta.",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Burlesque"
+  },
+  {
+    "id": 4,
+    "name": "Hoppy Mosh",
+    "category": "Cervezas Artesanales",
+    "description": "IPA - 6.0º - IBU 38 - SRM 33 - Cerveza Ale cobriza con intensas notas a frutas tropicales.",
+    "price": 6500,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Hoppy+Mosh"
+  },
+  {
+    "id": 5,
+    "name": "Bendicion Gitana",
+    "category": "Cervezas Artesanales",
+    "description": "Blonde Ale - 5,0º - IBU 15 - SRM 3 - Lager ligera",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Bendicion+Gitana"
+  },
+  {
+    "id": 6,
+    "name": "Marzen",
+    "category": "Cervezas Artesanales",
+    "description": "Abv: 5,8 Ibu: 22. Estilo Märzenbier, es una cerveza lager típica alemana donde prevalecen las notas a pan tostado y corteza,. Color ámbar brillante.",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Marzen"
+  },
+  {
+    "id": 7,
+    "name": "Tropical Stout",
+    "category": "Cervezas Artesanales",
+    "description": "ABV: 7,5 IBU:40 Carbonatación media-alta. Cerveza ligeramente dulce, con tonos vinosos y a ron oscuro, aromas a grano con un final que recuerdan a café y a cacao tostado. En boca deja una sensación suave y cremosa. Sus lúpulos de adición tardía recuerden a frutas como durazno, higos secos y piña. De aspecto oscuro, espuma color canela que se mantiene y sabores suavemente torrados, aunque mas lupulada de lo normal, serán el complemento perfecto para disfrutar los lluviosos días de invierno.",
+    "price": 5500,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Tropical+Stout"
+  },
+  {
+    "id": 8,
+    "name": "Mexican Lager",
+    "category": "Cervezas Artesanales",
+    "description": "Cerveceza muy ligera, chispeante y refrescante. De color dorado claro. Fácil de beber y de baja graduación alcohólica. Abv: 4,5% Ibu 15",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Mexican+Lager"
+  },
+  {
+    "id": 9,
+    "name": "Klein Alkoholfrei",
+    "category": "Cervezas Artesanales",
+    "description": "Cerveza de solo 0.4º - IBU 15- SRM 10 Cerveza de cuerpo medio con sabores y aromas a pan y galleta dulce, amargor medio bajo, con un final balanceado.",
+    "price": 5000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Klein+Alkoholfrei"
+  },
+  {
+    "id": 10,
+    "name": "Rye IPA",
+    "category": "Cervezas Artesanales",
+    "description": "Rye IPA | 6.5% ABV – 40 IBU Cerveza lupulada con un 15% de centeno, lo que le aporta en boca una textura ligera pero intensa, similar a una clásica IPA americana. Elaborada con lúpulos Simcoe, Amarillo y Centennial. MARIDAJE: Pizza pollo BBQ, Rodeo Burger",
+    "price": 6400,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Rye+IPA"
+  },
+  {
+    "id": 11,
+    "name": "Queen Burlesque",
+    "category": "Cervezas Artesanales",
+    "description": "Ibu : 25 Abv: 8 grados Estilo: American Strong Ale Cerveza ale fuerte, de color cobrizo oscuro, destaca su maltosidad y Lupulos americanos; sabores a caramelo, toffe y pasas negras.",
+    "price": 6000,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Queen+Burlesque"
+  },
+  {
+    "id": 12,
+    "name": "Soviet Riot",
+    "category": "Cervezas Artesanales",
+    "description": "Russian Imperial Stout - 8.8º - IBU 60 - SRM 40 - Cerveza Ale negra, intensa y compleja que a pesar de su graduación alcohólica, es balanceada. Notas a caramelo y café.",
+    "price": 6500,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Soviet+Riot"
+  },
+  {
+    "id": 13,
+    "name": "Heller Doppelbock Barrica",
+    "category": "Cervezas Artesanales",
+    "description": "Cerveza colaborativa con Ergo, de color dorado, con sabores a corteza de pan y notas florales. De amargor moderado. Con fácil tomabilidad a pesar de su graduación alcohólica, ligera en boca con un pequeño dulzor residual gracias a la miel. Abv 9 grados Ibu 23",
+    "price": 6200,
+    "image": "https://placehold.co/300x200/1a1a1a/e53e3e?text=Cerveza+Heller+Doppelbock+Barrica"
+  }
+]

+ 117 - 0
public/index.html

@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Biergarten Klein - Pedidos</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
+    <link rel="stylesheet" href="styles.css">
+    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
+    <script type="module" src="js/app.js"></script>
+</head>
+<body class="min-h-screen flex flex-col text-base leading-relaxed">
+
+    <div id="welcomeModal" class="modal" style="display: flex;">
+        <div class="modal-content text-center">
+            <h3 class="text-2xl font-semibold mb-4 accent-red">Bienvenido a Biergarten Klein</h3>
+            <p class="text-gray-400 mb-6">Para comenzar, por favor ingresa tu nombre y número de mesa.</p>
+            <div class="space-y-4">
+                <input type="text" id="userNameInput" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-accent-red focus:border-transparent outline-none text-white" placeholder="Tu Nombre">
+                <input type="number" id="userTableInput" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-accent-red focus:border-transparent outline-none text-white" placeholder="Número de Mesa">
+            </div>
+            <button id="startOrderButton" class="w-full mt-6 bg-accent-red hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300 ease-in-out transform hover:scale-105">
+                Comenzar Pedido
+            </button>
+        </div>
+    </div>
+
+    <header class="bg-black shadow-lg py-4">
+        <div class="container mx-auto px-4 text-center">
+            <h1 class="text-3xl font-bold accent-red tracking-tight">Biergarten <span class="text-white">Klein</span></h1>
+            <p class="text-gray-400 text-md mt-1">¡El sabor de la buena compañía!</p>
+        </div>
+    </header>
+
+    <main class="container mx-auto px-4 py-8 flex-grow grid grid-cols-1 lg:grid-cols-3 gap-8">
+        
+        <aside class="lg:col-span-1">
+            <div id="chat-container" class="sticky top-8 chat-panel-embedded">
+                <div class="chat-header">
+                    <h3 class="text-xl font-semibold accent-red">Chef IA <span class="text-sm text-gray-400">(Asistente Virtual)</span></h3>
+                </div>
+                <div class="chat-content-area">
+                    <div id="chatMessages" class="scrollable-chat">
+                        <div class="chat-bubble chat-bubble-ai">¡Hola! Soy tu asistente en Biergarten Klein. ¿Te gustaría una recomendación de nuestras cervezas artesanales?</div>
+                    </div>
+                    <div id="aiLoadingIndicator" class="hidden my-2">
+                        <div class="loading-spinner"></div>
+                        <p class="text-center text-sm text-gray-400 mt-1">Chef IA está pensando...</p>
+                    </div>
+                    <div class="chat-input-container">
+                        <input type="text" id="chatInput" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-accent-red focus:border-transparent outline-none text-white" placeholder="Escribe tu mensaje...">
+                        <button id="sendChatButton" class="bg-accent-red hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg transition duration-300">
+                            Enviar
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </aside>
+
+        <div class="lg:col-span-2 space-y-8">
+            <section id="productListSection">
+                <h2 class="category-title text-3xl font-semibold mb-4 pb-2">Nuestras Cervezas</h2>
+                <div id="productList" class="space-y-6">
+                    </div>
+            </section>
+
+            <aside id="cartAside">
+                <div class="sticky top-8">
+                    <h2 class="text-3xl font-semibold mb-4 border-b-2 border-accent-red pb-2">Tu Pedido</h2>
+                    <div id="cartItems" class="space-y-4 mb-6 product-card p-6 rounded-lg shadow-xl min-h-[150px]">
+                        <p id="emptyCartText" class="text-gray-400">Tu carrito está vacío.</p>
+                    </div>
+                    <div class="product-card p-6 rounded-lg shadow-xl">
+                        <div class="flex justify-between items-center text-xl font-semibold mb-4">
+                            <span>Total:</span>
+                            <span id="cartTotal" class="accent-red">$0</span>
+                        </div>
+                        <button id="checkoutButton" class="w-full bg-accent-red hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300 ease-in-out transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
+                            Finalizar Pedido
+                        </button>
+                    </div>
+                </div>
+            </aside>
+        </div>
+    </main>
+
+    <div id="orderConfirmationModal" class="modal">
+        <div class="modal-content text-center">
+            <span id="closeConfirmationModalButton" class="close-button absolute top-3 right-4">&times;</span>
+            <h3 class="text-2xl font-semibold mb-4 accent-red">¡Pedido Realizado!</h3>
+            <p class="text-lg mb-2">Gracias por tu compra en Biergarten Klein.</p>
+            <p class="text-gray-400 mb-6">Tu pedido está siendo preparado y llegará pronto.</p>
+            <svg class="w-16 h-16 text-green-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
+            <button id="newOrderButton" class="bg-accent-red hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
+                Hacer un Nuevo Pedido
+            </button>
+        </div>
+    </div>
+    <div id="orderErrorModal" class="modal">
+        <div class="modal-content text-center">
+            <span id="closeOrderErrorModalButton" class="close-button absolute top-3 right-4">&times;</span>
+            <h3 class="text-2xl font-semibold mb-4 accent-red">¡Error al enviar el pedido!</h3>
+            <p class="text-lg mb-2">Hubo un problema al enviar tu pedido. Por favor, inténtalo de nuevo.</p>
+            <svg class="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 9.172L14.828 14.828M14.828 9.172L9.172 14.828M12 2a10 10 0 110 20 10 10 0 010-20z"></path></svg>
+            <button id="retryButton" class="bg-accent-red hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">Reintentar</button>
+        </div>
+    </div>
+
+    <footer class="bg-black py-6 mt-auto border-t border-gray-800">
+        <div class="container mx-auto px-4 text-center text-gray-500">
+            <p>&copy; <span id="currentYear"></span> Biergarten Klein. Todos los derechos reservados.</p>
+            <p>Diseñado con <span class="accent-red">&hearts;</span> para los amantes de la buena cerveza.</p>
+        </div>
+    </footer>
+</body>
+</html>

+ 321 - 0
public/js/app.js

@@ -0,0 +1,321 @@
+import { initializeChat as serviceInitializeChat, sendMessage as serviceSendMessage, sendOrder, getProducts } from './service.js';
+
+let userName = '';
+let userTable = null;
+
+let products = [];
+
+let cart = [];
+let chatHistory = [
+    { role: "system", content: "¡Hola! Soy tu asistente en Biergarten Klein. ¿Te gustaría una recomendación de nuestras cervezas artesanales?" }
+];
+
+// --- Elementos del DOM ---
+const productListElement = document.getElementById("productList");
+const cartItemsElement = document.getElementById("cartItems");
+const cartTotalElement = document.getElementById("cartTotal");
+const emptyCartTextElement = document.getElementById("emptyCartText");
+const checkoutButton = document.getElementById("checkoutButton");
+const originalCheckoutButtonText = checkoutButton ? checkoutButton.textContent : "Finalizar Pedido";
+
+const chatMessagesElement = document.getElementById("chatMessages");
+const chatInputElement = document.getElementById("chatInput");
+const sendChatButton = document.getElementById("sendChatButton");
+const aiLoadingIndicator = document.getElementById("aiLoadingIndicator");
+
+// --- Modales ---
+const welcomeModal = document.getElementById("welcomeModal");
+const startOrderButton = document.getElementById("startOrderButton");
+const userNameInput = document.getElementById("userNameInput");
+const userTableInput = document.getElementById("userTableInput");
+
+const orderConfirmationModal = document.getElementById("orderConfirmationModal");
+const closeConfirmationModalButton = document.getElementById("closeConfirmationModalButton");
+const orderErrorModal = document.getElementById("orderErrorModal");
+const closeOrderErrorModalButton = document.getElementById("closeOrderErrorModalButton");
+const newOrderButton = document.getElementById("newOrderButton");
+const retryButton = document.getElementById("retryButton");
+
+// --- Loader Global ---
+let globalLoaderElement = null;
+
+function createGlobalLoader() {
+    if (document.getElementById('globalLoader')) return;
+    globalLoaderElement = document.createElement('div');
+    globalLoaderElement.id = 'globalLoader';
+    globalLoaderElement.className = 'fixed inset-0 bg-black bg-opacity-80 flex flex-col items-center justify-center z-[2000] transition-opacity duration-300 ease-in-out pointer-events-none';
+    globalLoaderElement.style.opacity = '0';
+    globalLoaderElement.innerHTML = `
+        <div style="border: 6px solid rgba(255, 255, 255, 0.2); border-radius: 50%; border-top: 6px solid #dc2626; width: 60px; height: 60px; animation: spin 1s linear infinite;"></div>
+        <p class="text-white text-xl mt-4">Procesando su pedido...</p>
+    `;
+    document.body.appendChild(globalLoaderElement);
+}
+
+function showGlobalLoader() {
+    if (!globalLoaderElement) createGlobalLoader();
+    globalLoaderElement.style.display = 'flex';
+    setTimeout(() => { if (globalLoaderElement) globalLoaderElement.style.opacity = '1'; }, 10);
+}
+
+function hideGlobalLoader() {
+    if (globalLoaderElement) {
+        globalLoaderElement.style.opacity = '0';
+        setTimeout(() => { if (globalLoaderElement) globalLoaderElement.style.display = 'none'; }, 300);
+    }
+}
+
+document.getElementById("currentYear").textContent = new Date().getFullYear().toString();
+
+function formatPrice(price) {
+    return price.toLocaleString("es-CL", { style: "currency", currency: "CLP" });
+}
+
+async function processOrder() {
+    if (cart.length === 0) return;
+    showGlobalLoader();
+    if (checkoutButton) {
+        checkoutButton.disabled = true;
+        checkoutButton.textContent = "Procesando...";
+    }
+
+    try {
+        const orderData = {
+            customerName: userName,
+            table: userTable,
+            items: cart.map(item => ({ id: item.id, name: item.name, quantity: item.quantity, price: item.price, itemTotal: item.price * item.quantity })),
+            totalAmount: cart.reduce((sum, item) => sum + item.price * item.quantity, 0),
+            orderDate: new Date().toISOString(),
+        };
+        console.log("Enviando Pedido:", orderData);
+        await sendOrder(orderData);
+        if (orderConfirmationModal) orderConfirmationModal.style.display = "flex";
+    } catch (error) {
+        console.error("Error al procesar la orden:", error);
+        if (orderErrorModal) {
+            const errorMessageElement = orderErrorModal.querySelector('p.text-lg');
+            if (errorMessageElement) {
+                errorMessageElement.textContent = `Hubo un problema: ${error.message || "Por favor, inténtalo de nuevo."}`;
+            }
+            orderErrorModal.style.display = "flex";
+        }
+    } finally {
+        hideGlobalLoader();
+        if (checkoutButton) {
+            checkoutButton.disabled = cart.length === 0;
+            checkoutButton.textContent = originalCheckoutButtonText;
+        }
+    }
+}
+
+async function renderProducts() {
+    if (!productListElement) return;
+    productListElement.innerHTML = "";
+    products = await getProducts();
+    products.forEach(product => {
+        const productCardContainer = document.createElement('div');
+        productCardContainer.innerHTML = `
+            <div class="product-card rounded-lg shadow-lg overflow-hidden transition-all duration-300 hover:shadow-2xl hover:transform hover:-translate-y-1 flex flex-col sm:flex-row">
+                <img src="${product.image}" alt="[Imagen de ${product.name}]" class="w-full sm:w-1/3 h-48 sm:h-auto object-cover">
+                <div class="p-6 flex flex-col justify-between flex-grow">
+                    <div>
+                        <h4 class="text-xl font-semibold mb-1">${product.name}</h4>
+                        <p class="text-gray-400 text-base mb-3">${product.description}</p>
+                    </div>
+                    <div class="flex justify-between items-center mt-4">
+                        <span class="text-2xl font-bold accent-red">${formatPrice(product.price)}</span>
+                        <button data-product-id="${product.id}" class="add-to-cart-btn bg-accent-red hover:bg-red-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-300 ease-in-out text-sm">
+                            Agregar
+                        </button>
+                    </div>
+                </div>
+            </div>
+        `;
+        productListElement.appendChild(productCardContainer.firstElementChild);
+    });
+
+    document.querySelectorAll('.add-to-cart-btn').forEach(button => {
+        button.addEventListener('click', (event) => {
+            const productId = parseInt(event.target.closest('button').dataset.productId);
+            addToCart(productId, event.target.closest('button'));
+        });
+    });
+}
+
+window.addToCart = async (productId, buttonElement = null) => {
+    const product = products.find(p => p.id === productId);
+    if (!product) return;
+    const cartItem = cart.find(item => item.id === productId);
+    if (cartItem) {
+        cartItem.quantity++;
+    } else {
+        cart.push({ ...product, quantity: 1 });
+    }
+
+    if (buttonElement) {
+        const originalText = buttonElement.textContent;
+        buttonElement.textContent = "✔ Agregado!";
+        buttonElement.classList.replace('bg-accent-red', 'bg-green-500');
+        buttonElement.classList.remove("hover:bg-red-700");
+        buttonElement.disabled = true;
+        setTimeout(() => {
+            buttonElement.textContent = originalText;
+            buttonElement.classList.replace('bg-green-500', 'bg-accent-red');
+            buttonElement.classList.add("hover:bg-red-700");
+            buttonElement.disabled = false;
+        }, 1500);
+    }
+    updateCartDisplay();
+};
+
+window.removeFromCart = (productId, removeAll = false) => {
+    const itemIndex = cart.findIndex(item => item.id === productId);
+    if (itemIndex > -1) {
+        if (removeAll || cart[itemIndex].quantity === 1) {
+            cart.splice(itemIndex, 1);
+        } else {
+            cart[itemIndex].quantity--;
+        }
+    }
+    updateCartDisplay();
+};
+
+function updateCartDisplay() {
+    if (!cartItemsElement || !emptyCartTextElement || !checkoutButton) return;
+    cartItemsElement.innerHTML = "";
+    if (cart.length === 0) {
+        emptyCartTextElement.classList.remove("hidden");
+        checkoutButton.disabled = true;
+    } else {
+        emptyCartTextElement.classList.add("hidden");
+        checkoutButton.disabled = false;
+        cart.forEach(item => {
+            const cartItemHTML = `
+                <div class="flex justify-between items-center border-b border-gray-700 pb-2 last:border-b-0 mb-2">
+                    <div>
+                        <h4 class="font-semibold text-base">${item.name} <span class="text-sm text-gray-400">(x${item.quantity})</span></h4>
+                        <p class="text-sm accent-red">${formatPrice(item.price * item.quantity)}</p>
+                    </div>
+                    <div class="flex items-center gap-1 sm:gap-2">
+                        <button onclick="addToCart(${item.id})" class="text-green-500 hover:text-green-400 text-lg sm:text-xl font-bold p-1 rounded-full hover:bg-gray-700 transition-colors">+</button>
+                        <button onclick="removeFromCart(${item.id})" class="text-yellow-500 hover:text-yellow-400 text-lg sm:text-xl font-bold p-1 rounded-full hover:bg-gray-700 transition-colors">-</button>
+                        <button onclick="removeFromCart(${item.id}, true)" class="text-red-500 hover:text-red-400 text-base sm:text-lg p-1 rounded-full hover:bg-gray-700 transition-colors">
+                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 sm:w-5 sm:h-5 pointer-events-none"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.56 0c1.153 0 2.24.032 3.22.096M15 5.79V4.5A2.25 2.25 0 0012.75 2.25h-1.5A2.25 2.25 0 009 4.5v1.29m0 0L9 19.5M15 5.79l-1.5-1.5M9 5.79l1.5-1.5" /></svg>
+                        </button>
+                    </div>
+                </div>
+            `;
+            cartItemsElement.innerHTML += cartItemHTML;
+        });
+    }
+    calculateTotal();
+}
+
+function calculateTotal() {
+    if (!cartTotalElement) return;
+    const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
+    cartTotalElement.textContent = formatPrice(total);
+}
+
+// --- Lógica del Chat ---
+function displayChatMessage(sender, message) {
+    if (!chatMessagesElement) return;
+    const bubbleClass = sender === "user" ? "chat-bubble-user" : "chat-bubble-ai";
+    const messageDiv = document.createElement("div");
+    messageDiv.classList.add("chat-bubble", bubbleClass);
+    messageDiv.innerHTML = sender === "ai" && window.marked ? marked.parse(message) : message;
+    chatMessagesElement.appendChild(messageDiv);
+    chatMessagesElement.scrollTop = chatMessagesElement.scrollHeight;
+}
+
+async function sendMessageToAI() {
+    if (!chatInputElement || !aiLoadingIndicator) return;
+    const userInput = chatInputElement.value.trim();
+    if (!userInput) return;
+
+    displayChatMessage("user", userInput);
+    chatInputElement.value = '';
+    aiLoadingIndicator.classList.remove("hidden");
+
+    try {
+        const response = await serviceSendMessage(userInput, chatHistory);
+        if (!response) {
+            displayChatMessage("ai", "Hubo un problema al conectar con el Chef IA.");
+        } else if (response === "not_init") {
+            displayChatMessage("ai", "El chat no está inicializado. Intentando reconectar...");
+            if (await serviceInitializeChat()) {
+                 displayChatMessage("ai", "Reconexión exitosa. Por favor, envía tu mensaje de nuevo.");
+            } else {
+                 displayChatMessage("ai", "Fallo la reconexión. Por favor, refresca la página.");
+            }
+        } else if (response.assistantResponse) {
+            chatHistory = response.messageList;
+            displayChatMessage("ai", response.assistantResponse);
+        }
+    } catch (error) {
+        console.error("Error enviando mensaje a IA:", error);
+        displayChatMessage("ai", `Error: ${error.message || "No se pudo conectar con el Chef IA."}`);
+    } finally {
+        aiLoadingIndicator.classList.add("hidden");
+        if (chatInputElement) chatInputElement.focus();
+    }
+}
+
+// --- Event Listeners ---
+document.addEventListener("DOMContentLoaded", async () => {
+    createGlobalLoader();
+    updateCartDisplay();
+    
+    if (welcomeModal && startOrderButton) {
+        startOrderButton.addEventListener('click', () => {
+            const name = userNameInput.value.trim();
+            const table = userTableInput.value;
+            if (name && table) {
+                userName = name;
+                userTable = parseInt(table);
+                welcomeModal.style.display = 'none';
+                // Iniciar la app después de obtener los datos
+                initializeApp();
+            } else {
+                alert('Por favor, ingresa tu nombre y número de mesa para continuar.');
+            }
+        });
+    }
+
+    async function initializeApp() {
+        await renderProducts();
+        try {
+            if (await serviceInitializeChat()) {
+                console.log("Chat AI Asistente inicializado exitosamente.");
+            } else {
+                console.warn("Chat AI no pudo inicializarse.");
+                displayChatMessage("ai", "No se pudo conectar con el Chef IA en este momento.");
+            }
+        } catch (error) {
+            console.error("Error durante la inicialización del Chat AI:", error);
+            displayChatMessage("ai", "Error al iniciar el Chef IA.");
+        }
+    }
+});
+
+if (checkoutButton) checkoutButton.addEventListener("click", processOrder);
+if (closeConfirmationModalButton) closeConfirmationModalButton.addEventListener("click", () => { orderConfirmationModal.style.display = "none"; });
+if (closeOrderErrorModalButton) closeOrderErrorModalButton.addEventListener("click", () => { orderErrorModal.style.display = "none"; });
+if (newOrderButton) {
+    newOrderButton.addEventListener("click", () => {
+        orderConfirmationModal.style.display = "none";
+        cart = [];
+        updateCartDisplay();
+    });
+}
+if (retryButton) retryButton.addEventListener("click", () => { orderErrorModal.style.display = "none"; });
+
+if (sendChatButton) sendChatButton.addEventListener("click", sendMessageToAI);
+if (chatInputElement) {
+    chatInputElement.addEventListener("keypress", (event) => {
+        if (event.key === "Enter") {
+            event.preventDefault();
+            sendMessageToAI();
+        }
+    });
+}

+ 0 - 0
public/js/interfaces.js


+ 80 - 0
public/js/service.js

@@ -0,0 +1,80 @@
+// public/ts/service.ts
+var chatToken = null;
+
+async function initializeChat() {
+  try {
+    const response = await fetch("/api/chat/init-chat");
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({ message: "Error desconocido al inicializar." }));
+      throw new Error(errorData.message || `Error del servidor: ${response.status}`);
+    }
+    const data = await response.json();
+    chatToken = data.chatToken;
+    if (!chatToken) {
+      throw new Error("No se pudo obtener el token de chat.");
+    }
+    return true;
+  } catch (error) {
+    console.error("Error al inicializar el chat:", error);
+  }
+}
+async function sendMessage(message, messageList) {
+  if (!chatToken) {
+    return "not_init";
+    return;
+  }
+  messageList.push({ role: "user", content: message });
+  const response = await fetch("/api/chat/completions", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      "X-App-Token": chatToken
+    },
+    body: JSON.stringify({ messages: messageList })
+  });
+  if (!response.ok) {
+    const errorData = await response.json().catch(() => ({ message: "Respuesta no válida del servidor." }));
+    throw new Error(errorData.message || `Error del servidor: ${response.status}`);
+  }
+  const data = await response.json();
+  const assistantResponse = data.response;
+  if (assistantResponse) {
+    messageList.push({ role: "assistant", content: assistantResponse });
+    return { messageList, assistantResponse };
+  } else {
+    throw new Error("Respuesta vacía del asistente.");
+  }
+}
+
+async function sendOrder(order) {
+  try {
+    const response = await fetch("/api/printer/order", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        "X-App-Token": chatToken
+      },
+      body: JSON.stringify(order)
+    });
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({ message: "Respuesta no válida del servidor." }));
+      throw new Error(errorData.message || `Error del servidor: ${response.status}`);
+    }
+    const data = await response.json();
+    return data;
+  } catch (error) {
+    console.error("Error al enviar la orden:", error);
+    throw error;
+  }
+}
+
+async function getProducts(){
+  const response = await fetch("/api/get_products");
+  if (!response.ok) {
+    const errorData = await response.json().catch(() => ({ message: "Respuesta no válida del servidor." }));
+    throw new Error(errorData.message || `Error del servidor: ${response.status}`);
+  }
+  const data = await response.json();
+  return data.products;
+}
+export { initializeChat, sendMessage, sendOrder, getProducts };

+ 177 - 0
public/styles.css

@@ -0,0 +1,177 @@
+/* Paleta de Colores Suavizada y Moderna */
+:root {
+    --background-dark: #121212;
+    --background-card: #1e1e1e;
+    --background-header: #0a0a0a;
+    --border-color: #2e2e2e;
+    --text-primary: #e0e0e0;
+    --text-secondary: #a0a0a0;
+    --accent-red: #dc2626; /* Rojo (Tailwind red-600) */
+    --accent-red-hover: #b91c1c; /* Rojo más oscuro (Tailwind red-700) */
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body {
+    font-family: 'Inter', sans-serif;
+    background-color: var(--background-dark);
+    color: var(--text-primary);
+    font-size: 16px;
+    line-height: 1.625;
+}
+
+.accent-red { color: var(--accent-red); }
+.bg-accent-red { background-color: var(--accent-red); }
+.bg-accent-red.hover\:bg-red-700:hover { background-color: var(--accent-red-hover); }
+.border-accent-red { border-color: var(--accent-red); }
+
+header.bg-black { background-color: var(--background-header); }
+footer.bg-black { background-color: var(--background-header); }
+.border-gray-800 { border-color: var(--border-color); }
+
+.product-card {
+    background-color: var(--background-card);
+    border: 1px solid var(--border-color);
+}
+.category-title {
+    color: var(--accent-red);
+    border-bottom: 2px solid var(--accent-red);
+}
+
+/* Chat Panel Integrado */
+.chat-panel-embedded {
+    border-radius: 12px;
+    overflow: hidden;
+    height: 75vh; /* Altura fija */
+    max-height: 800px;
+    background-color: var(--background-card);
+    border: 1px solid var(--border-color);
+    box-shadow: 0 5px 15px rgba(0,0,0,0.3);
+    display: flex;
+    flex-direction: column;
+}
+
+.chat-header {
+    background-color: #1a1a1a;
+    padding: 1rem 1.5rem;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px solid var(--border-color);
+}
+
+.chat-content-area {
+    padding: 1rem;
+    flex-grow: 1;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+.scrollable-chat {
+    flex-grow: 1;
+    overflow-y: auto;
+    margin-bottom: 1rem;
+    padding-right: 0.5rem;
+}
+
+.chat-bubble {
+    max-width: 85%;
+    padding: 10px 15px;
+    border-radius: 15px;
+    margin-bottom: 10px;
+    word-wrap: break-word;
+    font-size: 0.95rem;
+    line-height: 1.5;
+}
+.chat-bubble-user {
+    background-color: var(--accent-red);
+    color: white;
+    margin-left: auto;
+    border-bottom-right-radius: 5px;
+}
+.chat-bubble-ai {
+    background-color: #3b3b3b;
+    color: var(--text-primary);
+    margin-right: auto;
+    border-bottom-left-radius: 5px;
+}
+
+.chat-input-container {
+    display: flex;
+    gap: 0.5rem;
+    width: 100%;
+    margin-top: auto;
+}
+
+/* Scrollbar Styles */
+.scrollable-chat::-webkit-scrollbar { width: 8px; }
+.scrollable-chat::-webkit-scrollbar-track { background: #2d2d2d; border-radius: 10px; }
+.scrollable-chat::-webkit-scrollbar-thumb { background: #555; border-radius: 10px; }
+.scrollable-chat::-webkit-scrollbar-thumb:hover { background: var(--accent-red); }
+
+/* Modal Styles */
+.modal {
+    display: none;
+    position: fixed;
+    z-index: 1050;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    overflow: auto;
+    background-color: rgba(0,0,0,0.85);
+    align-items: center;
+    justify-content: center;
+}
+.modal-content {
+    background-color: var(--background-card);
+    padding: 2rem;
+    border: 1px solid var(--border-color);
+    width: 90%;
+    max-width: 450px;
+    border-radius: 12px;
+    position: relative;
+}
+.close-button {
+    color: #aaa;
+    font-size: 28px;
+    font-weight: bold;
+    cursor: pointer;
+    background: none;
+    border: none;
+}
+.close-button:hover,
+.close-button:focus {
+    color: var(--accent-red);
+    text-decoration: none;
+}
+
+/* Loader Styles */
+.loading-spinner {
+    border: 4px solid rgba(255, 255, 255, 0.3);
+    border-radius: 50%;
+    border-top: 4px solid var(--accent-red);
+    width: 24px;
+    height: 24px;
+    animation: spin 1s linear infinite;
+    margin: 0 auto;
+}
+
+@keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+}
+
+/* Media Queries para Responsividad */
+@media (max-width: 1023px) {
+    .chat-panel-embedded {
+        height: auto; /* Altura automática en móvil */
+        margin-bottom: 2rem;
+    }
+    .chat-content-area {
+        height: 40vh; /* Altura específica para el contenido del chat */
+    }
+}

+ 8 - 0
requirements.txt

@@ -0,0 +1,8 @@
+fastapi
+uvicorn[standard]
+openai
+python-dotenv
+starlette 
+itsdangerous
+tabulate
+python-escpos[usb]