up
This commit is contained in:
@@ -757,6 +757,16 @@ func MigratePortal() {
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateLanding crea/actualiza la tabla de sesiones del Landing Generator.
|
||||
func MigrateLanding() {
|
||||
db := app.Http.Database.DB
|
||||
if err := db.AutoMigrate(&models.LandingSession{}); err != nil {
|
||||
log.Printf("[MIGRATE] Error en MigrateLanding: %v", err)
|
||||
} else {
|
||||
log.Println("[MIGRATE] Tabla landing_sessions OK")
|
||||
}
|
||||
}
|
||||
|
||||
// SeedTelegram agrega el submódulo de Telegram al módulo "Integraciones". Es idempotente.
|
||||
func SeedTelegram() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LandingSession representa una sesión de generación de landing page.
|
||||
// Cada sesión tiene un token único que se usa como URL pública de preview.
|
||||
type LandingSession struct {
|
||||
gorm.Model
|
||||
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
||||
UserName string `json:"user_name" gorm:"column:user_name"`
|
||||
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
||||
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
||||
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
||||
UserJob string `json:"user_job" gorm:"column:user_job"`
|
||||
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
||||
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
||||
// Historial de mensajes almacenado como JSON
|
||||
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
||||
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
||||
// status: in_progress | generated | paid | expired
|
||||
Status string `json:"status" gorm:"column:status;default:'in_progress'"`
|
||||
BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"`
|
||||
BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"`
|
||||
BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"`
|
||||
PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"`
|
||||
VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"`
|
||||
}
|
||||
|
||||
func (LandingSession) TableName() string { return "landing_sessions" }
|
||||
|
||||
// LandingMessage es un mensaje del chat (serializado en AnswersJSON).
|
||||
type LandingMessage struct {
|
||||
Role string `json:"role"` // "assistant" | "user"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GetAnswers deserializa el historial de mensajes.
|
||||
func (s *LandingSession) GetAnswers() []LandingMessage {
|
||||
var msgs []LandingMessage
|
||||
if s.AnswersJSON == "" {
|
||||
return msgs
|
||||
}
|
||||
_ = json.Unmarshal([]byte(s.AnswersJSON), &msgs)
|
||||
return msgs
|
||||
}
|
||||
|
||||
// SetAnswers serializa el historial de mensajes.
|
||||
func (s *LandingSession) SetAnswers(msgs []LandingMessage) {
|
||||
b, _ := json.Marshal(msgs)
|
||||
s.AnswersJSON = string(b)
|
||||
}
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func CreateLandingSession(token, firstQuestion string) (*LandingSession, error) {
|
||||
msgs := []LandingMessage{{Role: "assistant", Content: firstQuestion}}
|
||||
b, _ := json.Marshal(msgs)
|
||||
s := &LandingSession{
|
||||
Token: token,
|
||||
Status: "in_progress",
|
||||
AnswersJSON: string(b),
|
||||
}
|
||||
if err := app.Http.Database.DB.Create(s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func GetLandingSessionByToken(token string) (*LandingSession, error) {
|
||||
var s LandingSession
|
||||
if err := app.Http.Database.DB.Where("token = ?", token).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateLandingSession(s *LandingSession) error {
|
||||
return app.Http.Database.DB.Save(s).Error
|
||||
}
|
||||
|
||||
// GetAllLandingSessions devuelve sesiones paginadas para el panel admin.
|
||||
func GetAllLandingSessions(limit, offset int, status string) ([]LandingSession, int64, error) {
|
||||
var items []LandingSession
|
||||
var total int64
|
||||
q := app.Http.Database.DB.Model(&LandingSession{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/helpers"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// landingSecret verifica que el header X-Landing-Secret sea correcto.
|
||||
// Si LANDING_API_SECRET no está configurado, se rechaza por seguridad.
|
||||
func landingSecret(c *fiber.Ctx) bool {
|
||||
secret := os.Getenv("LANDING_API_SECRET")
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
return c.Get("X-Landing-Secret") == secret
|
||||
}
|
||||
|
||||
// ─── POST /landing/session ────────────────────────────────────────────────────
|
||||
|
||||
// LandingCreateSession crea una nueva sesión de chat para el generador de landing.
|
||||
func LandingCreateSession(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
|
||||
}
|
||||
|
||||
const firstQuestion = "¡Hola! Soy tu asistente de landing pages. ¿Cuál es tu nombre o el nombre de tu negocio?"
|
||||
|
||||
// Token simple: alfanumérico, 16 chars
|
||||
token := helpers.RandomString(16)
|
||||
|
||||
session, err := models.CreateLandingSession(token, firstQuestion)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"id": session.ID, "token": session.Token})
|
||||
}
|
||||
|
||||
// ─── POST /landing/chat ───────────────────────────────────────────────────────
|
||||
|
||||
var landingQuestions = []struct {
|
||||
Key string
|
||||
Question string
|
||||
}{
|
||||
{"user_name", "¡Hola! Soy tu asistente de landing pages. ¿Cuál es tu nombre o el nombre de tu negocio?"},
|
||||
{"business_type", "¿A qué se dedica tu negocio? Descríbelo brevemente."},
|
||||
{"target", "¿A quién va dirigida tu landing page? ¿Cuál es tu cliente ideal?"},
|
||||
{"offer", "¿Qué producto o servicio principal quieres destacar?"},
|
||||
{"value_prop", "¿Cuál es tu propuesta de valor? ¿Por qué deberían elegirte a ti y no a la competencia?"},
|
||||
{"cta", "¿Qué acción quieres que tome el visitante? (Ej: agendar cita, comprar, contactar, etc.)"},
|
||||
{"colors", "¿Tienes colores de marca? Descríbelos o dime el estilo visual que prefieres. (Ej: moderno, minimalista, corporativo, colorido)"},
|
||||
{"user_phone", "¿Cuál es tu número de teléfono o WhatsApp de contacto?"},
|
||||
{"user_email", "¿Cuál es tu correo electrónico de contacto?"},
|
||||
{"user_website", "¿Tienes sitio web o redes sociales que quieras enlazar? (Escríbelos o escribe \"no\" si no tienes)"},
|
||||
{"user_address", "¿Tienes una dirección física o ciudad donde operas?"},
|
||||
{"extras", "¿Hay algo más que quieras incluir? Testimonios, precios, galería, FAQ... (O escribe \"listo\" para generar ya tu landing)"},
|
||||
}
|
||||
|
||||
// LandingChat recibe el mensaje del usuario y devuelve la siguiente pregunta.
|
||||
func LandingChat(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
|
||||
}
|
||||
|
||||
type body struct {
|
||||
Token string `json:"token"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
var req body
|
||||
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.Message == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token y message son requeridos"})
|
||||
}
|
||||
|
||||
session, err := models.GetLandingSessionByToken(req.Token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
|
||||
}
|
||||
if session.Status != "in_progress" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sesión ya finalizada"})
|
||||
}
|
||||
|
||||
msgs := session.GetAnswers()
|
||||
userAnswersCount := 0
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" {
|
||||
userAnswersCount++
|
||||
}
|
||||
}
|
||||
|
||||
msgs = append(msgs, models.LandingMessage{Role: "user", Content: req.Message})
|
||||
|
||||
// Guardar campo estructurado si corresponde
|
||||
contactFields := map[string]bool{"user_name": true, "user_phone": true, "user_email": true, "user_website": true, "user_address": true}
|
||||
if userAnswersCount < len(landingQuestions) {
|
||||
key := landingQuestions[userAnswersCount].Key
|
||||
if contactFields[key] {
|
||||
switch key {
|
||||
case "user_name":
|
||||
session.UserName = req.Message
|
||||
case "user_phone":
|
||||
session.UserPhone = req.Message
|
||||
case "user_email":
|
||||
session.UserEmail = req.Message
|
||||
case "user_website":
|
||||
session.UserWebsite = req.Message
|
||||
case "user_address":
|
||||
session.UserAddress = req.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextIndex := userAnswersCount + 1
|
||||
var nextMessage string
|
||||
readyToGenerate := false
|
||||
|
||||
if nextIndex < len(landingQuestions) {
|
||||
nextMessage = landingQuestions[nextIndex].Question
|
||||
} else {
|
||||
readyToGenerate = true
|
||||
nextMessage = "¡Perfecto! Ya tengo toda la información. ¿Listo para ver tu landing page generada por IA? 🚀"
|
||||
}
|
||||
|
||||
msgs = append(msgs, models.LandingMessage{Role: "assistant", Content: nextMessage})
|
||||
session.SetAnswers(msgs)
|
||||
|
||||
if err := models.UpdateLandingSession(session); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": nextMessage,
|
||||
"readyToGenerate": readyToGenerate,
|
||||
"questionIndex": nextIndex,
|
||||
"totalQuestions": len(landingQuestions),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── GET /landing/preview/:token ─────────────────────────────────────────────
|
||||
|
||||
// LandingGetSession devuelve los datos públicos de una sesión (sin HTML si no está pagada).
|
||||
func LandingGetSession(c *fiber.Ctx) error {
|
||||
token := c.Params("token")
|
||||
session, err := models.GetLandingSessionByToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
|
||||
resp := fiber.Map{
|
||||
"id": session.ID,
|
||||
"token": session.Token,
|
||||
"status": session.Status,
|
||||
"bold_paid": session.BoldPaid,
|
||||
"price_cop": session.PriceCOP,
|
||||
"user_name": session.UserName,
|
||||
"user_email": session.UserEmail,
|
||||
"user_phone": session.UserPhone,
|
||||
"user_company": session.UserCompany,
|
||||
"user_job": session.UserJob,
|
||||
"user_website": session.UserWebsite,
|
||||
"user_address": session.UserAddress,
|
||||
"vcard_included": session.VcardIncluded,
|
||||
}
|
||||
// Solo exponer el HTML si la sesión está generada (preview con marca de agua siempre)
|
||||
if session.HTMLContent != "" {
|
||||
resp["html_content"] = session.HTMLContent
|
||||
}
|
||||
return c.JSON(resp)
|
||||
}
|
||||
|
||||
// LandingGetAnswers devuelve el historial completo de mensajes (requiere secret).
|
||||
func LandingGetAnswers(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
|
||||
}
|
||||
token := c.Params("token")
|
||||
session, err := models.GetLandingSessionByToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"answers": session.GetAnswers(),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── POST /landing/generate ───────────────────────────────────────────────────
|
||||
|
||||
// LandingGenerate delega la generación del HTML a un servicio externo (Qwen).
|
||||
// Recibe el HTML ya generado desde el Next.js y lo persiste.
|
||||
func LandingGenerate(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
|
||||
}
|
||||
|
||||
type body struct {
|
||||
Token string `json:"token"`
|
||||
HTMLContent string `json:"html_content"`
|
||||
VcardIncluded bool `json:"vcard_included"`
|
||||
}
|
||||
var req body
|
||||
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.HTMLContent == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token y html_content requeridos"})
|
||||
}
|
||||
|
||||
session, err := models.GetLandingSessionByToken(req.Token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
|
||||
}
|
||||
|
||||
session.HTMLContent = req.HTMLContent
|
||||
session.Status = "generated"
|
||||
session.VcardIncluded = req.VcardIncluded
|
||||
|
||||
if err := models.UpdateLandingSession(session); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "token": session.Token})
|
||||
}
|
||||
|
||||
// ─── POST /landing/payment ────────────────────────────────────────────────────
|
||||
|
||||
// LandingCreatePayment genera el link de pago Bold para la sesión.
|
||||
func LandingCreatePayment(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
|
||||
}
|
||||
|
||||
type body struct {
|
||||
Token string `json:"token"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
var req body
|
||||
if err := c.BodyParser(&req); err != nil || req.Token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token requerido"})
|
||||
}
|
||||
|
||||
session, err := models.GetLandingSessionByToken(req.Token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
|
||||
}
|
||||
|
||||
if session.BoldPaid {
|
||||
return c.JSON(fiber.Map{"paid": true, "token": session.Token})
|
||||
}
|
||||
|
||||
if session.BoldLinkID != "" {
|
||||
boldURL := "https://checkout.bold.co/payment/" + session.BoldLinkID
|
||||
return c.JSON(fiber.Map{"url": boldURL, "link_id": session.BoldLinkID})
|
||||
}
|
||||
|
||||
cfg, err := models.GetBoldConfig()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "sin configuración Bold activa"})
|
||||
}
|
||||
|
||||
reference := "landing-" + session.Token
|
||||
callbackURL := req.CallbackURL
|
||||
|
||||
result, err := services.CreateBoldPaymentLink(cfg, services.BoldPaymentLinkRequest{
|
||||
AmountType: "CLOSE",
|
||||
Amount: services.BoldAmountField{Currency: "COP", TotalAmount: session.PriceCOP},
|
||||
Description: "Landing Page Profesional",
|
||||
Reference: reference,
|
||||
PayerEmail: session.UserEmail,
|
||||
CallbackURL: callbackURL,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
session.BoldLinkID = result.Payload.PaymentLink
|
||||
_ = models.UpdateLandingSession(session)
|
||||
|
||||
return c.JSON(fiber.Map{"url": result.Payload.URL, "link_id": result.Payload.PaymentLink})
|
||||
}
|
||||
|
||||
// ─── GET /landing/poll/:token ─────────────────────────────────────────────────
|
||||
|
||||
// LandingPollPayment verifica el estado del pago (polling desde el cliente).
|
||||
func LandingPollPayment(c *fiber.Ctx) error {
|
||||
token := c.Params("token")
|
||||
|
||||
session, err := models.GetLandingSessionByToken(token)
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"paid": false})
|
||||
}
|
||||
|
||||
if session.BoldPaid {
|
||||
return c.JSON(fiber.Map{"paid": true})
|
||||
}
|
||||
|
||||
// Consultar la API de Bold si hay un link_id
|
||||
if session.BoldLinkID != "" {
|
||||
cfg, err := models.GetBoldConfig()
|
||||
if err == nil {
|
||||
paid, paymentID, _, checkErr := services.CheckBoldLinkPaid(cfg, session.BoldLinkID)
|
||||
if checkErr == nil && paid {
|
||||
session.BoldPaid = true
|
||||
session.BoldPaymentID = paymentID
|
||||
session.Status = "paid"
|
||||
_ = models.UpdateLandingSession(session)
|
||||
return c.JSON(fiber.Map{"paid": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"paid": false})
|
||||
}
|
||||
|
||||
// ─── GET /landing/download/:token ────────────────────────────────────────────
|
||||
|
||||
// LandingDownload entrega el HTML (o ZIP) solo si la sesión está pagada.
|
||||
func LandingDownload(c *fiber.Ctx) error {
|
||||
if !landingSecret(c) {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("no autorizado")
|
||||
}
|
||||
|
||||
token := c.Params("token")
|
||||
session, err := models.GetLandingSessionByToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).SendString("no encontrado")
|
||||
}
|
||||
if !session.BoldPaid {
|
||||
return c.Status(fiber.StatusPaymentRequired).SendString("pago requerido")
|
||||
}
|
||||
if session.HTMLContent == "" {
|
||||
return c.Status(fiber.StatusNotFound).SendString("HTML no generado")
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Set("Content-Disposition", "attachment; filename=\"landing.html\"")
|
||||
return c.SendString(session.HTMLContent)
|
||||
}
|
||||
|
||||
// ─── GET /app/landing-pages (admin) ──────────────────────────────────────────
|
||||
|
||||
// LandingAdminList lista todas las sesiones para el panel de administración.
|
||||
func LandingAdminList(c *fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
perPage := 20
|
||||
offset := (page - 1) * perPage
|
||||
statusFilter := c.Query("status", "")
|
||||
|
||||
sessions, total, err := models.GetAllLandingSessions(perPage, offset, statusFilter)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"data": sessions,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
@@ -62,4 +62,16 @@ func RutasPublicas(web fiber.Router) {
|
||||
})
|
||||
// ─── Página de estado del sistema (Atlassian Statuspage) ────────────────
|
||||
web.Get("/status", controllers.StatusPage)
|
||||
|
||||
// ─── Landing Generator (acceso con X-Landing-Secret) ─────────────────────
|
||||
// Rutas públicas (sin secret) para lectura de preview y polling
|
||||
web.Get("/landing/preview/:token", apiControllers.LandingGetSession)
|
||||
web.Get("/landing/poll/:token", apiControllers.LandingPollPayment)
|
||||
// Rutas internas (requieren X-Landing-Secret del Next.js)
|
||||
web.Post("/landing/session", apiControllers.LandingCreateSession)
|
||||
web.Post("/landing/chat", apiControllers.LandingChat)
|
||||
web.Post("/landing/generate", apiControllers.LandingGenerate)
|
||||
web.Post("/landing/payment", apiControllers.LandingCreatePayment)
|
||||
web.Get("/landing/answers/:token", apiControllers.LandingGetAnswers)
|
||||
web.Get("/landing/download/:token", apiControllers.LandingDownload)
|
||||
}
|
||||
|
||||
@@ -235,6 +235,9 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink)
|
||||
protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus)
|
||||
|
||||
// ─── Landing Generator (admin) ────────────────────────────────────────────
|
||||
protected.Get("/landing-pages", middlewares.MenuMiddleware, apiControllers.LandingAdminList)
|
||||
|
||||
// ─── Productos SaaS ──────────────────────────────────────────────────────
|
||||
protected.Get("/saas", middlewares.MenuMiddleware, controllers.SaasIndex)
|
||||
protected.Get("/loadsaas", controllers.GetSaasProductos)
|
||||
|
||||
Reference in New Issue
Block a user