first
This commit is contained in:
commit
087641d050
17
.env
Normal file
17
.env
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Base de données
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=secret
|
||||||
|
DB_NAME=whatsapp_saas
|
||||||
|
|
||||||
|
# Environnement
|
||||||
|
ENV=dev
|
||||||
|
|
||||||
|
# WhatsApp Business API
|
||||||
|
WHATSAPP_TOKEN=EAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=123456789012345
|
||||||
|
WHATSAPP_WEBHOOK_TOKEN=secrettoken
|
||||||
|
|
||||||
|
# Port d'écoute de l'app
|
||||||
|
PORT=3003
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Étape unique : build + exécution dans une seule image (évite les erreurs GLIBC)
|
||||||
|
FROM golang:1.24
|
||||||
|
|
||||||
|
# Définir le dossier de travail
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier les fichiers Go
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copier tout le projet
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Compiler le projet (assure-toi que main.go est à ./backend/main.go)
|
||||||
|
RUN go build -o server ./backend/main.go
|
||||||
|
|
||||||
|
# Copier les templates HTMX
|
||||||
|
COPY frontend/templates /app/frontend/templates
|
||||||
|
COPY frontend/assets /app/frontend/assets
|
||||||
|
|
||||||
|
# Exposer le port (adapté à ton choix)
|
||||||
|
EXPOSE 3003
|
||||||
|
|
||||||
|
# Commande de lancement
|
||||||
|
CMD ["./server"]
|
||||||
45
Makefile
Normal file
45
Makefile
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Nom du projet
|
||||||
|
PROJECT_NAME = whatsapp-saas
|
||||||
|
|
||||||
|
# Cible par défaut
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Usage: make [target]"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " up Lance l'application (docker-compose up -d)"
|
||||||
|
@echo " down Stoppe l'application (docker-compose down)"
|
||||||
|
@echo " rebuild Reconstruit et relance l'app (docker-compose up --build)"
|
||||||
|
@echo " logs Affiche les logs de l'application"
|
||||||
|
@echo " clean Supprime containers, volumes, images"
|
||||||
|
@echo " db Accède au MariaDB en ligne de commande"
|
||||||
|
|
||||||
|
# Lancer les containers
|
||||||
|
.PHONY: up
|
||||||
|
up:
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Stopper les containers
|
||||||
|
.PHONY: down
|
||||||
|
down:
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Rebuilder tout
|
||||||
|
.PHONY: rebuild
|
||||||
|
rebuild:
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# Logs de l'app
|
||||||
|
.PHONY: logs
|
||||||
|
logs:
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Nettoyer tous les artefacts Docker liés au projet
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
docker-compose down -v --rmi all --remove-orphans
|
||||||
|
|
||||||
|
# Accéder au shell MariaDB
|
||||||
|
.PHONY: db
|
||||||
|
db:
|
||||||
|
docker exec -it whatsapp_saas_db mariadb -uroot -psecret whatsapp_saas
|
||||||
60
backend/datafixture/main.go
Normal file
60
backend/datafixture/main.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// seed_users.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cangui/whatsapp/backend/models"
|
||||||
|
"fmt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
|
||||||
|
os.Getenv("DB_USER"),
|
||||||
|
os.Getenv("DB_PASS"),
|
||||||
|
os.Getenv("DB_HOST"),
|
||||||
|
os.Getenv("DB_PORT"),
|
||||||
|
os.Getenv("DB_NAME"),
|
||||||
|
)
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to connect database")
|
||||||
|
}
|
||||||
|
|
||||||
|
users := []models.User{
|
||||||
|
{
|
||||||
|
Email: "info@pointvirgule.net",
|
||||||
|
Password: hash("test"),
|
||||||
|
SSOID: "admin001",
|
||||||
|
Role: models.ROLE_ADMIN,
|
||||||
|
WhatsappToken: "EAAYuBH5IDeQBOZBT3lVkpW48657qUFarq4ihCR9aUzlatERQsPxkKuK3bFFHnTbyYpfcPmIdZCko4FJupceC52dunyIuIidTZBZCFTbTaVkQfYZBfJu3rtr6B3ZBtwcVFnm0AVXi8Lj5TmZCPLcEEgxupVtlVdsOvIdMpOWJZCOBC56FzTgA8fRijzFNMlOLuPAEW7Y1f2eMVd5Ku3avy6m5d4JAxrxZCcv1fZBfLXWLZBV",
|
||||||
|
WhatsappPhoneNumberID: "644541092077960",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Email: "canguijc@gmail.com",
|
||||||
|
Password: hash("test"),
|
||||||
|
SSOID: "client001",
|
||||||
|
Role: models.ROLE_CLIENT,
|
||||||
|
WhatsappToken: "EAAYuBH5IDeQBOZBT3lVkpW48657qUFarq4ihCR9aUzlatERQsPxkKuK3bFFHnTbyYpfcPmIdZCko4FJupceC52dunyIuIidTZBZCFTbTaVkQfYZBfJu3rtr6B3ZBtwcVFnm0AVXi8Lj5TmZCPLcEEgxupVtlVdsOvIdMpOWJZCOBC56FzTgA8fRijzFNMlOLuPAEW7Y1f2eMVd5Ku3avy6m5d4JAxrxZCcv1fZBfLXWLZBV",
|
||||||
|
WhatsappPhoneNumberID: "644541092077960",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
var existing models.User
|
||||||
|
db.Where("email = ?", user.Email).First(&existing)
|
||||||
|
if existing.ID == 0 {
|
||||||
|
db.Create(&user)
|
||||||
|
fmt.Println("✅ Créé:", user.Email)
|
||||||
|
} else {
|
||||||
|
fmt.Println("⏩ Existe déjà:", user.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(password string) string {
|
||||||
|
h, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(h)
|
||||||
|
}
|
||||||
54
backend/db/db.go
Normal file
54
backend/db/db.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cangui/whatsapp/backend/models"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func InitDB() *gorm.DB {
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
user := os.Getenv("DB_USER")
|
||||||
|
pass := os.Getenv("DB_PASS")
|
||||||
|
name := os.Getenv("DB_NAME")
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local", user, pass, host, port, name)
|
||||||
|
|
||||||
|
var db *gorm.DB
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Retry loop (attente de MariaDB)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("⏳ Tentative %d: BDD pas encore prête (%s)\n", i+1, err)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic("❌ Failed to connect to database: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Connexion à MariaDB réussie !")
|
||||||
|
err = db.AutoMigrate(
|
||||||
|
&models.User{},
|
||||||
|
&models.Consumption{},
|
||||||
|
&models.MonthlyConsumption{},
|
||||||
|
&models.Conversation{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic("Migration failed: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
149
backend/handlers/handlers_test.go
Normal file
149
backend/handlers/handlers_test.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"cangui/whatsapp/backend/handlers"
|
||||||
|
"cangui/whatsapp/backend/models"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initTestDB() *gorm.DB {
|
||||||
|
database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
database.AutoMigrate(&models.User{}, &models.Consumption{}, &models.MonthlyConsumption{}, &models.Conversation{})
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestUser(db *gorm.DB) models.User {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
|
||||||
|
user := models.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
Password: string(hash),
|
||||||
|
SSOID: "ssoid123",
|
||||||
|
Role: models.ROLE_ADMIN,
|
||||||
|
IsActive: true,
|
||||||
|
WhatsappToken: "test-token",
|
||||||
|
WhatsappPhoneNumberID: "123456",
|
||||||
|
}
|
||||||
|
db.Create(&user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
h := handlers.CreateUser(db)
|
||||||
|
|
||||||
|
user := models.User{
|
||||||
|
Email: "new@example.com",
|
||||||
|
Password: "newpass",
|
||||||
|
SSOID: "newssoid",
|
||||||
|
Role: models.ROLE_CLIENT,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(user)
|
||||||
|
r := httptest.NewRequest("POST", "/api/user/create", bytes.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginHandler_Invalid(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
h := handlers.LoginHandler(db)
|
||||||
|
r := httptest.NewRequest("POST", "/api/login", bytes.NewReader([]byte(`{}`)))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWhatsAppMessage_Unauthorized(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
h := handlers.SendWhatsAppMessage(db)
|
||||||
|
r := httptest.NewRequest("POST", "/api/message/send", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMessageFromPayload_Text(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"to": "33600000000",
|
||||||
|
"type": "text",
|
||||||
|
"text": map[string]interface{}{"body": "Bonjour"},
|
||||||
|
}
|
||||||
|
msg, err := models.BuildMessageFromPayload(payload)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMessageFromPayload_Invalid(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"to": "33600000000",
|
||||||
|
"type": "unknown",
|
||||||
|
}
|
||||||
|
_, err := models.BuildMessageFromPayload(payload)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllUsers(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
createTestUser(db)
|
||||||
|
h := handlers.GetAllUsers(db)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/api/user/all", nil)
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByID(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
user := createTestUser(db)
|
||||||
|
h := handlers.GetUserByID(db)
|
||||||
|
r := httptest.NewRequest("GET", "/api/user/1", nil)
|
||||||
|
r = mux.SetURLVars(r, map[string]string{"id": "1"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var u models.User
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &u)
|
||||||
|
assert.Equal(t, user.Email, u.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateUser(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
createTestUser(db)
|
||||||
|
h := handlers.UpdateUser(db)
|
||||||
|
|
||||||
|
updated := map[string]interface{}{
|
||||||
|
"email": "updated@example.com",
|
||||||
|
"role": "ADMIN",
|
||||||
|
"is_active": true,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(updated)
|
||||||
|
r := httptest.NewRequest("PUT", "/api/user/update/1", bytes.NewReader(body))
|
||||||
|
r = mux.SetURLVars(r, map[string]string{"id": "1"})
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser(t *testing.T) {
|
||||||
|
db := initTestDB()
|
||||||
|
createTestUser(db)
|
||||||
|
h := handlers.DeleteUser(db)
|
||||||
|
r := httptest.NewRequest("DELETE", "/api/user/delete/1", nil)
|
||||||
|
r = mux.SetURLVars(r, map[string]string{"id": "1"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||||
|
}
|
||||||
391
backend/handlers/main.go
Normal file
391
backend/handlers/main.go
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cangui/whatsapp/backend/jwt"
|
||||||
|
"cangui/whatsapp/backend/models"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simuler un SSO + Redirection selon rôle
|
||||||
|
func LoginHandler(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
||||||
|
var reqUser models.User
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqUser); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
result := db.Where("email = ?", reqUser.Email).First(&user)
|
||||||
|
if result.Error != nil {
|
||||||
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification du mot de passe hashé
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(reqUser.Password)); err != nil {
|
||||||
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création du JWT
|
||||||
|
fmt.Printf("login");
|
||||||
|
fmt.Printf(user.SSOID)
|
||||||
|
tokenString, err := jwt.CreateToken(user.SSOID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie HTTP-only
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: tokenString,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false, // à mettre à true en prod HTTPS
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
// HX-Redirect pour HTMX
|
||||||
|
w.Header().Set("HX-Redirect", "/dashboard")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"message":"Login success"}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func SSOLoginHandler(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
var user models.User
|
||||||
|
if result := db.Where("sso_id = ?", code).First(&user); result.Error != nil || !user.IsActive {
|
||||||
|
http.Error(w, "Invalid SSO code", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.CreateToken(user.Email)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Token error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func SendWhatsAppMessage(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
val := r.Context().Value("ssoid")
|
||||||
|
ssoid, ok := val.(string)
|
||||||
|
if !ok || ssoid == "" {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ SSOID reçu depuis le contexte :", ssoid)
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur en base via le SSOID
|
||||||
|
var user models.User
|
||||||
|
if err := db.Where("sso_id = ?", ssoid).First(&user).Error; err != nil || user.ID == 0 {
|
||||||
|
http.Error(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📨 Payload reçu : %+v\n", payload)
|
||||||
|
|
||||||
|
message, err := models.BuildMessageFromPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.MarshalIndent(message, "", " ") // joli format
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode message", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://graph.facebook.com/v22.0/%s/messages", user.WhatsappPhoneNumberID)
|
||||||
|
|
||||||
|
fmt.Println("📡 Envoi de la requête à :", apiURL)
|
||||||
|
fmt.Println("📦 JSON envoyé :")
|
||||||
|
fmt.Println(string(jsonBody))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+user.WhatsappToken)
|
||||||
|
|
||||||
|
fmt.Println("📋 Headers :")
|
||||||
|
for key, vals := range req.Header {
|
||||||
|
for _, v := range vals {
|
||||||
|
fmt.Printf(" %s: %s\n", key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to contact WhatsApp API", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 🛠 debug réponse
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("✅ Réponse WhatsApp (%d) : %s\n", resp.StatusCode, string(respBody))
|
||||||
|
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var MessageTypeCreditCost = map[string]uint{
|
||||||
|
"text": 1,
|
||||||
|
"image": 2,
|
||||||
|
"video": 2,
|
||||||
|
"audio": 1,
|
||||||
|
"document": 2,
|
||||||
|
"sticker": 1,
|
||||||
|
"interactive": 1,
|
||||||
|
"reaction": 0,
|
||||||
|
"location": 1,
|
||||||
|
"contacts": 1,
|
||||||
|
}
|
||||||
|
func WebhookHandler(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := body["entry"].([]interface{})[0].(map[string]interface{})
|
||||||
|
changes := entry["changes"].([]interface{})[0].(map[string]interface{})
|
||||||
|
value := changes["value"].(map[string]interface{})
|
||||||
|
|
||||||
|
// Gestion des messages entrants
|
||||||
|
if msgs, ok := value["messages"]; ok {
|
||||||
|
messages := msgs.([]interface{})
|
||||||
|
for _, msg := range messages {
|
||||||
|
m := msg.(map[string]interface{})
|
||||||
|
waID := value["contacts"].([]interface{})[0].(map[string]interface{})["wa_id"].(string)
|
||||||
|
msgID := m["id"].(string)
|
||||||
|
typeMsg := m["type"].(string)
|
||||||
|
content := extractMessageContent(m)
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := db.Where("sso_id = ?", waID).First(&user).Error; err != nil {
|
||||||
|
fmt.Println("Utilisateur non trouvé pour:", waID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Create(&models.Conversation{
|
||||||
|
UserID: user.ID,
|
||||||
|
From: waID,
|
||||||
|
To: value["metadata"].(map[string]interface{})["display_phone_number"].(string),
|
||||||
|
MessageID: msgID,
|
||||||
|
Type: typeMsg,
|
||||||
|
Content: content,
|
||||||
|
Direction: "inbound",
|
||||||
|
})
|
||||||
|
|
||||||
|
credit := MessageTypeCreditCost[typeMsg]
|
||||||
|
if credit > 0 {
|
||||||
|
ConsumeCredits(db, &user, typeMsg, content, credit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des statuts (delivered, read, etc.)
|
||||||
|
if statuses, ok := value["statuses"]; ok {
|
||||||
|
for _, s := range statuses.([]interface{}) {
|
||||||
|
status := s.(map[string]interface{})
|
||||||
|
msgID := status["id"].(string)
|
||||||
|
state := status["status"].(string)
|
||||||
|
db.Model(&models.Conversation{}).Where("message_id = ?", msgID).Update("status", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func extractMessageContent(m map[string]interface{}) string {
|
||||||
|
t := m["type"].(string)
|
||||||
|
switch t {
|
||||||
|
case "text":
|
||||||
|
return m["text"].(map[string]interface{})["body"].(string)
|
||||||
|
case "image", "video", "audio", "document", "sticker":
|
||||||
|
return m[t].(map[string]interface{})["id"].(string)
|
||||||
|
case "interactive":
|
||||||
|
return m[t].(map[string]interface{})["type"].(string)
|
||||||
|
default:
|
||||||
|
return "[non textuel]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func ConsumeCredits(db *gorm.DB, user *models.User, messageType, content string, credits uint) {
|
||||||
|
user.CurrentMonthCredits -= credits
|
||||||
|
db.Save(user)
|
||||||
|
|
||||||
|
db.Create(&models.Consumption{
|
||||||
|
UserID: user.ID,
|
||||||
|
MessageType: messageType,
|
||||||
|
Description: content,
|
||||||
|
CreditsUsed: credits,
|
||||||
|
})
|
||||||
|
|
||||||
|
month := time.Now().Format("2006-01")
|
||||||
|
var mc models.MonthlyConsumption
|
||||||
|
db.Where("user_id = ? AND month = ?", user.ID, month).FirstOrInit(&mc)
|
||||||
|
mc.UserID = user.ID
|
||||||
|
mc.Month = month
|
||||||
|
mc.TotalUsed += credits
|
||||||
|
db.Save(&mc)
|
||||||
|
}
|
||||||
|
func GetUserConversations(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := strconv.Atoi(mux.Vars(r)["id"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversations []models.Conversation
|
||||||
|
if err := db.Where("user_id = ?", userID).
|
||||||
|
Order("created_at desc").
|
||||||
|
Find(&conversations).Error; err != nil {
|
||||||
|
http.Error(w, "Error retrieving conversations", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(conversations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebhookVerifyHandler répond au GET initial de vérification Meta
|
||||||
|
func WebhookVerifyHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mode := r.URL.Query().Get("hub.mode")
|
||||||
|
token := r.URL.Query().Get("hub.verify_token")
|
||||||
|
challenge := r.URL.Query().Get("hub.challenge")
|
||||||
|
|
||||||
|
if mode == "subscribe" && token == os.Getenv("WHATSAPP_WEBHOOK_TOKEN") {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(challenge))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("Invalid verify token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func CreateUser(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var user models.User
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Hash error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.Password = string(hash)
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
http.Error(w, "Create failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllUsers(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var users []models.User
|
||||||
|
db.Find(&users)
|
||||||
|
json.NewEncoder(w).Encode(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserByID(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.Atoi(mux.Vars(r)["id"])
|
||||||
|
var user models.User
|
||||||
|
if err := db.First(&user, id).Error; err != nil {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUser(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.Atoi(mux.Vars(r)["id"])
|
||||||
|
var user models.User
|
||||||
|
if err := db.First(&user, id).Error; err != nil {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input models.User
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Password != "" {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
|
input.Password = string(hash)
|
||||||
|
} else {
|
||||||
|
input.Password = user.Password
|
||||||
|
}
|
||||||
|
input.ID = user.ID
|
||||||
|
if err := db.Model(&user).Updates(input).Error; err != nil {
|
||||||
|
http.Error(w, "Update failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUser(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.Atoi(mux.Vars(r)["id"])
|
||||||
|
if err := db.Delete(&models.User{}, id).Error; err != nil {
|
||||||
|
http.Error(w, "Delete failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/jwt/jwtFunction.go
Normal file
79
backend/jwt/jwtFunction.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Dans le snippet de code ci-dessus, nous importons les colis nécessaires,
|
||||||
|
y compris github.com/golang-jwt/jwt/v5.
|
||||||
|
Nous créons un nouveau jeton JWT en utilisant le jwt.NewWithClaims()fonction.
|
||||||
|
Nous spécifions la méthode de signature comme HS256 et des informations pertinentes telles que le nom d'utilisateur
|
||||||
|
et la date d'expiration du jeton. Ensuite, nous signons le jeton avec une clé secrète et retournons
|
||||||
|
le jeton généré comme une chaîne.
|
||||||
|
*/
|
||||||
|
var secretKey = []byte("secret-key")
|
||||||
|
|
||||||
|
func CreateToken(username string) (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
|
||||||
|
jwt.MapClaims{
|
||||||
|
"username": username,
|
||||||
|
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
tokenString, err := token.SignedString(secretKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Sprintf(tokenString)
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Dans le snippet de code ci-dessus, nous utilisons le jwt.Parse()fonctionner pour
|
||||||
|
analyser et vérifier le jeton. Nous fournissons une fonction de rappel pour récupérer
|
||||||
|
la clé secrète utilisée pour signer le jeton. Si le jeton est valide, nous continuons à traiter la demande;
|
||||||
|
sinon, nous renvoyons une erreur indiquant que le jeton est invalide.
|
||||||
|
*/
|
||||||
|
func verifyToken(tokenString string) error {
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return secretKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func ProtectedHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
tokenString := r.Header.Get("Authorization")
|
||||||
|
if tokenString == "" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, "Missing authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenString = tokenString[len("Bearer "):]
|
||||||
|
|
||||||
|
err := verifyToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, "Invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, "Welcome to the the protected area")
|
||||||
|
|
||||||
|
}
|
||||||
35
backend/main.go
Normal file
35
backend/main.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cangui/whatsapp/backend/db"
|
||||||
|
"cangui/whatsapp/backend/middleware"
|
||||||
|
"cangui/whatsapp/backend/routes"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. Démarrer le routeur principal
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
// 2. Initialiser la DB
|
||||||
|
bd := db.InitDB()
|
||||||
|
|
||||||
|
// 3. Routes non protégées : on les monte sur le routeur principal
|
||||||
|
routes.RoutesPublic(r, bd)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Créer un sous-routeur pour les routes protégées
|
||||||
|
protected := r.PathPrefix("/").Subrouter()
|
||||||
|
|
||||||
|
// 5. Appliquer le middleware JWT à ce sous-routeur
|
||||||
|
protected.Use(middleware.AuthMiddleware)
|
||||||
|
// 6. Enregistrer les routes protégées sur ce sous-routeur
|
||||||
|
routes.RoutesProtected(protected, bd)
|
||||||
|
|
||||||
|
// 7. Lancer le serveur sur le port 4000
|
||||||
|
log.Fatal(http.ListenAndServe(":3003", r))
|
||||||
|
}
|
||||||
100
backend/middleware/middleware.go
Normal file
100
backend/middleware/middleware.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secretKey = []byte("secret-key")
|
||||||
|
|
||||||
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var tokenString string
|
||||||
|
|
||||||
|
// 1. Vérifie Authorization: Bearer ...
|
||||||
|
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Vérifie le cookie si pas de header
|
||||||
|
if tokenString == "" {
|
||||||
|
if cookie, err := r.Cookie("token"); err == nil {
|
||||||
|
tokenString = cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Vérifie le body JSON uniquement si POST ou PUT
|
||||||
|
if tokenString == "" && r.Method == http.MethodPost || r.Method == http.MethodPut {
|
||||||
|
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||||
|
bodyCopy, _ := io.ReadAll(r.Body)
|
||||||
|
var bodyMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(bodyCopy, &bodyMap); err == nil {
|
||||||
|
if val, ok := bodyMap["token"].(string); ok {
|
||||||
|
tokenString = val
|
||||||
|
// restaure le body
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenString == "" {
|
||||||
|
http.Error(w, "Token manquant", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Vérifie et parse le token
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("méthode signature invalide")
|
||||||
|
}
|
||||||
|
return secretKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
http.Error(w, "Token invalide", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Claims invalides", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// vérifie l’expiration
|
||||||
|
if exp, ok := claims["exp"].(float64); ok {
|
||||||
|
if time.Now().Unix() > int64(exp) {
|
||||||
|
http.Error(w, "Token expiré", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// récupère l’identifiant
|
||||||
|
ssoid, ok := claims["username"].(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "SSOID manquant", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// injection dans le contexte
|
||||||
|
ctx := context.WithValue(r.Context(), "ssoid", ssoid)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// helper pour remarshal le JSON quand il est relu après parse
|
||||||
|
func mustMarshal(data map[string]interface{}) []byte {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
378
backend/models/models.go
Normal file
378
backend/models/models.go
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ROLE_ADMIN UserRole = "ADMIN"
|
||||||
|
ROLE_CLIENT UserRole = "CLIENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
|
|
||||||
|
Password string `gorm:"not null"` // hashé avec bcrypt
|
||||||
|
Email string `gorm:"uniqueIndex;not null"`
|
||||||
|
SSOID string `gorm:"uniqueIndex;not null"`
|
||||||
|
Role UserRole `gorm:"type:enum('ADMIN','CLIENT');default:'CLIENT';not null"`
|
||||||
|
IsActive bool `gorm:"default:true"`
|
||||||
|
|
||||||
|
WhatsappToken string `gorm:"type:text"` // token Meta spécifique au client
|
||||||
|
WhatsappPhoneNumberID string `gorm:"type:varchar(50)"` // ID du numéro WhatsApp Business
|
||||||
|
|
||||||
|
|
||||||
|
MonthlyCredits uint `gorm:"default:100"`
|
||||||
|
CurrentMonthCredits uint `gorm:"default:100"`
|
||||||
|
LastRecharge *time.Time `gorm:"default:null"`
|
||||||
|
|
||||||
|
Consumption []Consumption
|
||||||
|
MonthlyConsumptions []MonthlyConsumption
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Consumption struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
UserID uint `gorm:"index;not null"`
|
||||||
|
MessageType string `gorm:"type:varchar(20);not null"` // ex: text, image, button
|
||||||
|
Description string `gorm:"type:text"`
|
||||||
|
CreditsUsed uint `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthlyConsumption struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
UserID uint `gorm:"index;not null"`
|
||||||
|
Month string `gorm:"type:char(7);index;not null"` // Format YYYY-MM
|
||||||
|
TotalUsed uint `gorm:"not null"` // Crédits utilisés ce mois
|
||||||
|
}
|
||||||
|
|
||||||
|
type WhatsappMessage struct {
|
||||||
|
MessagingProduct string `json:"messaging_product"` // always "whatsapp"
|
||||||
|
To string `json:"to"` // recipient phone number
|
||||||
|
Type string `json:"type"` // message type (text, image, video, etc.)
|
||||||
|
Context *Context `json:"context,omitempty"` // for replies
|
||||||
|
RecipientType string `json:"recipient_type,omitempty"` // optional
|
||||||
|
Text *Text `json:"text,omitempty"`
|
||||||
|
Image *Media `json:"image,omitempty"`
|
||||||
|
Video *Media `json:"video,omitempty"`
|
||||||
|
Document *Media `json:"document,omitempty"`
|
||||||
|
Audio *Media `json:"audio,omitempty"`
|
||||||
|
Sticker *Media `json:"sticker,omitempty"`
|
||||||
|
Location *Location `json:"location,omitempty"`
|
||||||
|
Contacts []Contact `json:"contacts,omitempty"`
|
||||||
|
Interactive *Interactive `json:"interactive,omitempty"`
|
||||||
|
Reaction *Reaction `json:"reaction,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
Street string `json:"street,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Zip string `json:"zip,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // home, work
|
||||||
|
}
|
||||||
|
type Contact struct {
|
||||||
|
Addresses []Address `json:"addresses,omitempty"`
|
||||||
|
Name *Name `json:"name,omitempty"`
|
||||||
|
Emails []Email `json:"emails,omitempty"`
|
||||||
|
Phones []Phone `json:"phones,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Name struct {
|
||||||
|
FormattedName string `json:"formatted_name"`
|
||||||
|
FirstName string `json:"first_name,omitempty"`
|
||||||
|
LastName string `json:"last_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Email struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Type string `json:"type,omitempty"` // work, home
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phone struct {
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
type Media struct {
|
||||||
|
ID string `json:"id,omitempty"` // media ID from upload
|
||||||
|
Link string `json:"link,omitempty"` // direct URL
|
||||||
|
Caption string `json:"caption,omitempty"` // for image/video
|
||||||
|
Filename string `json:"filename,omitempty"` // for documents
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
}
|
||||||
|
type Location struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Url string `json:"url,omitempty"`
|
||||||
|
IsLive bool `json:"is_live,omitempty"` // si demande de localisation en direct
|
||||||
|
}
|
||||||
|
type Text struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
PreviewURL bool `json:"preview_url,omitempty"`
|
||||||
|
}
|
||||||
|
type Context struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
}
|
||||||
|
type Reaction struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
type StatusIndicator struct {
|
||||||
|
Status string `json:"status"` // "read" or "typing"
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
type Interactive struct {
|
||||||
|
Type string `json:"type"` // button | list | flow
|
||||||
|
Body *BodyContent `json:"body"`
|
||||||
|
Action *Action `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BodyContent struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
Button string `json:"button,omitempty"`
|
||||||
|
Buttons []ReplyButton `json:"buttons,omitempty"`
|
||||||
|
Sections []Section `json:"sections,omitempty"` // for list
|
||||||
|
CatalogID string `json:"catalog_id,omitempty"` // for flow
|
||||||
|
ProductID string `json:"product_retailer_id,omitempty"` // for flow
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplyButton struct {
|
||||||
|
Type string `json:"type"` // reply
|
||||||
|
Reply ButtonReply `json:"reply"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonReply struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Rows []ListItem `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
type StatusMessage struct {
|
||||||
|
MessagingProduct string `json:"messaging_product"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Type string `json:"type"` // "typing_on", "typing_off", "read"
|
||||||
|
}
|
||||||
|
type Conversation struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
UserID uint `gorm:"index;not null"`
|
||||||
|
From string `gorm:"not null"`
|
||||||
|
To string `gorm:"not null"`
|
||||||
|
MessageID string `gorm:"uniqueIndex;not null"`
|
||||||
|
Type string `gorm:"not null"`
|
||||||
|
Content string `gorm:"type:text"`
|
||||||
|
Direction string `gorm:"not null"` // inbound/outbound
|
||||||
|
Status string `gorm:"type:varchar(20)"` // sent, delivered, read, failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTextMessage(to string, body string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "text",
|
||||||
|
Text: &Text{Body: body},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImageMessage(to, mediaID, caption string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "image",
|
||||||
|
Image: &Media{ID: mediaID, Caption: caption},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVideoMessage(to, mediaID, caption string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "video",
|
||||||
|
Video: &Media{ID: mediaID, Caption: caption},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioMessage(to, mediaID string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "audio",
|
||||||
|
Audio: &Media{ID: mediaID},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDocumentMessage(to, mediaID, filename string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "document",
|
||||||
|
Document: &Media{ID: mediaID, Filename: filename},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStickerMessage(to, mediaID string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "sticker",
|
||||||
|
Sticker: &Media{ID: mediaID},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContactMessage(to string, contact Contact) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "contacts",
|
||||||
|
Contacts: []Contact{contact},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocationMessage(to string, lat, lng float64, name, address string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "location",
|
||||||
|
Location: &Location{Latitude: lat, Longitude: lng, Name: name, Address: address},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReactionMessage(to, msgID, emoji string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "reaction",
|
||||||
|
Reaction: &Reaction{MessageID: msgID, Emoji: emoji},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReplyMessage(to, msgID, body string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "text",
|
||||||
|
Context: &Context{MessageID: msgID},
|
||||||
|
Text: &Text{Body: body},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInteractiveButtons(to string, text string, buttons []ReplyButton) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "interactive",
|
||||||
|
Interactive: &Interactive{Type: "button", Body: &BodyContent{Text: text}, Action: &Action{Buttons: buttons}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInteractiveList(to string, text string, sections []Section) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "interactive",
|
||||||
|
Interactive: &Interactive{Type: "list", Body: &BodyContent{Text: text}, Action: &Action{Sections: sections}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFlowMessage(to, text, catalogID, productID string) WhatsappMessage {
|
||||||
|
return WhatsappMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "interactive",
|
||||||
|
Interactive: &Interactive{Type: "flow", Body: &BodyContent{Text: text}, Action: &Action{CatalogID: catalogID, ProductID: productID}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTypingIndicator(to string) StatusMessage {
|
||||||
|
return StatusMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "typing_on",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadReceipt(to string) StatusMessage {
|
||||||
|
return StatusMessage{
|
||||||
|
MessagingProduct: "whatsapp",
|
||||||
|
To: to,
|
||||||
|
Type: "read",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func BuildMessageFromPayload(payload map[string]interface{}) (interface{}, error) {
|
||||||
|
to := fmt.Sprintf("%v", payload["to"])
|
||||||
|
typeStr := fmt.Sprintf("%v", payload["type"])
|
||||||
|
|
||||||
|
switch typeStr {
|
||||||
|
case "text":
|
||||||
|
if text, ok := payload["text"].(map[string]interface{}); ok {
|
||||||
|
return NewTextMessage(to, fmt.Sprintf("%v", text["body"])), nil
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
if image, ok := payload["image"].(map[string]interface{}); ok {
|
||||||
|
return NewImageMessage(to, fmt.Sprintf("%v", image["id"]), fmt.Sprintf("%v", image["caption"])), nil
|
||||||
|
}
|
||||||
|
case "video":
|
||||||
|
if video, ok := payload["video"].(map[string]interface{}); ok {
|
||||||
|
return NewVideoMessage(to, fmt.Sprintf("%v", video["id"]), fmt.Sprintf("%v", video["caption"])), nil
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
if audio, ok := payload["audio"].(map[string]interface{}); ok {
|
||||||
|
return NewAudioMessage(to, fmt.Sprintf("%v", audio["id"])), nil
|
||||||
|
}
|
||||||
|
case "document":
|
||||||
|
if doc, ok := payload["document"].(map[string]interface{}); ok {
|
||||||
|
return NewDocumentMessage(to, fmt.Sprintf("%v", doc["id"]), fmt.Sprintf("%v", doc["filename"])), nil
|
||||||
|
}
|
||||||
|
case "sticker":
|
||||||
|
if st, ok := payload["sticker"].(map[string]interface{}); ok {
|
||||||
|
return NewStickerMessage(to, fmt.Sprintf("%v", st["id"])), nil
|
||||||
|
}
|
||||||
|
case "location":
|
||||||
|
if loc, ok := payload["location"].(map[string]interface{}); ok {
|
||||||
|
lat := loc["latitude"].(float64)
|
||||||
|
lng := loc["longitude"].(float64)
|
||||||
|
return NewLocationMessage(to, lat, lng, fmt.Sprintf("%v", loc["name"]), fmt.Sprintf("%v", loc["address"])), nil
|
||||||
|
}
|
||||||
|
case "reaction":
|
||||||
|
if react, ok := payload["reaction"].(map[string]interface{}); ok {
|
||||||
|
return NewReactionMessage(to, fmt.Sprintf("%v", react["message_id"]), fmt.Sprintf("%v", react["emoji"])), nil
|
||||||
|
}
|
||||||
|
case "typing_on", "typing_off", "read":
|
||||||
|
return StatusMessage{MessagingProduct: "whatsapp", To: to, Type: typeStr}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported message type: %s", typeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to build message")
|
||||||
|
}
|
||||||
186
backend/renders/renders.go
Normal file
186
backend/renders/renders.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
package renders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cangui/whatsapp/backend/models"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func Login(w http.ResponseWriter, r *http.Request){
|
||||||
|
renderTemplate(w,"login",nil)
|
||||||
|
}
|
||||||
|
func JwtTest(w http.ResponseWriter, r *http.Request){
|
||||||
|
renderTemplate(w,"jwt",nil)
|
||||||
|
}
|
||||||
|
func TestMessagesPages(w http.ResponseWriter, r *http.Request){
|
||||||
|
renderTemplate(w,"test",nil)
|
||||||
|
}
|
||||||
|
func AdminUserEdit(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.Atoi(mux.Vars(r)["id"])
|
||||||
|
var user models.User
|
||||||
|
if err := db.First(&user, id).Error; err != nil {
|
||||||
|
http.Error(w, "Utilisateur introuvable", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"User": user,
|
||||||
|
}
|
||||||
|
renderPartial(w, "admin_user_edit", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func AdminUserCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"User": models.User{},
|
||||||
|
}
|
||||||
|
renderPartial(w, "admin_user_edit", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func AdminConversationPage(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := mux.Vars(r)["id"]
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"UserID": idStr,
|
||||||
|
}
|
||||||
|
renderTemplate(w, "admin_conversations", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminConversationRows(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := mux.Vars(r)["id"]
|
||||||
|
id, _ := strconv.Atoi(idStr)
|
||||||
|
typeFilter := r.URL.Query().Get("type")
|
||||||
|
|
||||||
|
var convs []models.Conversation
|
||||||
|
query := db.Where("user_id = ?", id)
|
||||||
|
if typeFilter != "" {
|
||||||
|
query = query.Where("type = ?", typeFilter)
|
||||||
|
}
|
||||||
|
query.Order("created_at desc").Find(&convs)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Conversations": convs,
|
||||||
|
"UserID": idStr,
|
||||||
|
}
|
||||||
|
renderPartial(w, "admin_conversations_rows", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminConversationThread(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := mux.Vars(r)["id"]
|
||||||
|
id, _ := strconv.Atoi(idStr)
|
||||||
|
|
||||||
|
var convs []models.Conversation
|
||||||
|
db.Where("user_id = ?", id).Order("created_at asc").Find(&convs)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Conversations": convs,
|
||||||
|
"UserID": idStr,
|
||||||
|
}
|
||||||
|
renderPartial(w, "admin_conversations_thread", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func Dashboard(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
val := r.Context().Value("ssoid")
|
||||||
|
ssoid, ok := val.(string)
|
||||||
|
if !ok || ssoid == "" {
|
||||||
|
http.Error(w, "Utilisateur non authentifié", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := db.Where("sso_id = ?", ssoid).First(&user).Error; err != nil {
|
||||||
|
http.Error(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := r.URL.Query().Get("filter")
|
||||||
|
var fromDate time.Time
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch filter {
|
||||||
|
case "today":
|
||||||
|
fromDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
case "week":
|
||||||
|
fromDate = now.AddDate(0, 0, -7)
|
||||||
|
default:
|
||||||
|
fromDate = time.Time{} // zero time: no filter
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUsers int64
|
||||||
|
var totalCredits uint
|
||||||
|
var totalConversations int64
|
||||||
|
var conversations []models.Conversation
|
||||||
|
|
||||||
|
if user.Role == models.ROLE_ADMIN {
|
||||||
|
db.Model(&models.User{}).Count(&totalUsers)
|
||||||
|
db.Model(&models.Consumption{}).Select("SUM(credits_used)").Scan(&totalCredits)
|
||||||
|
db.Model(&models.Conversation{}).Where("created_at >= ?", fromDate).Count(&totalConversations)
|
||||||
|
db.Where("created_at >= ?", fromDate).Order("created_at desc").Limit(10).Find(&conversations)
|
||||||
|
} else {
|
||||||
|
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&totalUsers)
|
||||||
|
db.Model(&models.Consumption{}).Where("user_id = ?", user.ID).Select("SUM(credits_used)").Scan(&totalCredits)
|
||||||
|
db.Model(&models.Conversation{}).Where("user_id = ? AND created_at >= ?", user.ID, fromDate).Count(&totalConversations)
|
||||||
|
db.Where("user_id = ? AND created_at >= ?", user.ID, fromDate).Order("created_at desc").Limit(10).Find(&conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"User": user,
|
||||||
|
"Stats": map[string]interface{}{
|
||||||
|
"TotalUsers": totalUsers,
|
||||||
|
"TotalCreditsUsed": totalCredits,
|
||||||
|
"TotalConversations": totalConversations,
|
||||||
|
},
|
||||||
|
"Conversations": conversations,
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTemplate(w, "dashboard", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func renderTemplate(w http.ResponseWriter, templ string, data map[string]interface{}) {
|
||||||
|
t, err := template.ParseFiles(
|
||||||
|
"./frontend/templates/head.pages.tmpl", // Template inclus
|
||||||
|
"./frontend/templates/" + templ + ".pages.tmpl", // Template principal
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécutez explicitement le template principal
|
||||||
|
err = t.ExecuteTemplate(w, templ+".pages.tmpl", data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPartial(w http.ResponseWriter, templ string, data map[string]interface{}) {
|
||||||
|
t, err := template.ParseFiles("./frontend/templates/" + templ + ".pages.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Execute(w, data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
71
backend/routes/routes.go
Normal file
71
backend/routes/routes.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cangui/whatsapp/backend/handlers"
|
||||||
|
"cangui/whatsapp/backend/renders"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes non protégées
|
||||||
|
func RoutesPublic(r *mux.Router, db *gorm.DB) {
|
||||||
|
|
||||||
|
// Fichiers statiques (CSS, JS, etc.)
|
||||||
|
staticDir := "./frontend/assets/"
|
||||||
|
r.PathPrefix("/frontend/assets/").Handler(
|
||||||
|
http.StripPrefix("/frontend/assets/", http.FileServer(http.Dir(staticDir))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Page de login
|
||||||
|
r.HandleFunc("/login", renders.Login)
|
||||||
|
r.HandleFunc("/api/whatsapp/webhook", handlers.WebhookVerifyHandler()).Methods("GET")
|
||||||
|
|
||||||
|
// Endpoint d'API pour se logger
|
||||||
|
r.HandleFunc("/api/login", handlers.LoginHandler(db)).Methods("POST")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes protégées
|
||||||
|
func RoutesProtected(r *mux.Router, db *gorm.DB) {
|
||||||
|
r.HandleFunc("/jwt", renders.JwtTest)
|
||||||
|
r.HandleFunc("/api/message/send", handlers.SendWhatsAppMessage(db)).Methods("POST")
|
||||||
|
r.HandleFunc("/admin/user/{id}/conversations", renders.AdminConversationPage(db))
|
||||||
|
r.HandleFunc("/api/user/{id}/conversations", renders.AdminConversationRows(db))
|
||||||
|
r.HandleFunc("/admin/user/{id}/edit", renders.AdminUserEdit(db)).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/user/new", renders.AdminUserCreate()).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard", renders.Dashboard(db))
|
||||||
|
r.HandleFunc("/test/send", renders.TestMessagesPages)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// // Ici on place les vues et API qui doivent être protégées
|
||||||
|
// r.HandleFunc("/stream", StreamHandler)
|
||||||
|
// r.HandleFunc("/dashboard", renders.Dashboard(bd))
|
||||||
|
// r.HandleFunc("/settings", renders.Settings)
|
||||||
|
// r.HandleFunc("/library", renders.Library)
|
||||||
|
// r.HandleFunc("/menuLibary", renders.Library)
|
||||||
|
// r.HandleFunc("/godownloader/downloads", renders.GoDownload)
|
||||||
|
// r.HandleFunc("/godownloader/linkcollectors", renders.GoDownloadLinkCollectors)
|
||||||
|
// r.HandleFunc("/godownloader/settings", renders.GoDownloadSetting)
|
||||||
|
// // API user
|
||||||
|
// r.HandleFunc("/api/user/create", users.CreateUser(bd)).Methods("POST")
|
||||||
|
// r.HandleFunc("/api/user/update/{id}", users.UpdateUser(bd)).Methods("PUT")
|
||||||
|
// r.HandleFunc("/api/user/delete/{id}", users.DeleteUser(bd)).Methods("DELETE")
|
||||||
|
// r.HandleFunc("/api/user/all/", users.ReadAllUser(bd)).Methods("GET")
|
||||||
|
// r.HandleFunc("/api/user/{id}", users.FindUserById(bd)).Methods("GET")
|
||||||
|
|
||||||
|
// // API download
|
||||||
|
// r.HandleFunc("/api/pathDownload/create", download.CreateSavePath(bd)).Methods("POST")
|
||||||
|
// r.HandleFunc("/api/pathDownload/update/{id}", download.UpdateSavePath(bd)).Methods("PUT")
|
||||||
|
// r.HandleFunc("/api/pathDownload/delete/{id}", download.DeleteSavePath(bd)).Methods("DELETE")
|
||||||
|
// r.HandleFunc("/api/pathDownload/all/", download.ReadAllSavePath(bd)).Methods("GET")
|
||||||
|
|
||||||
|
// //API Check path
|
||||||
|
// r.HandleFunc("/validate-path", download.PathValidationHandler)
|
||||||
|
|
||||||
|
//API Scan folder
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: whatsapp_saas_app
|
||||||
|
platform: linux/amd64
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_USER=root
|
||||||
|
- DB_PASS=secret
|
||||||
|
- DB_NAME=whatsapp_saas
|
||||||
|
- ENV=dev
|
||||||
|
- WHATSAPP_TOKEN=${WHATSAPP_TOKEN}
|
||||||
|
- WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID}
|
||||||
|
- WHATSAPP_WEBHOOK_TOKEN=${WHATSAPP_WEBHOOK_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./frontend/templates:/app/frontend/templates
|
||||||
|
- ./frontend/assets:/app/frontend/assets
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:10.6
|
||||||
|
container_name: whatsapp_saas_db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: secret
|
||||||
|
MARIADB_DATABASE: whatsapp_saas
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin
|
||||||
|
container_name: whatsapp_saas_phpmyadmin
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: root
|
||||||
|
PMA_PASSWORD: secret
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
6
frontend/assets/css/all.min.css
vendored
Normal file
6
frontend/assets/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4085
frontend/assets/css/boostrap/bootstrap-grid.css
vendored
Normal file
4085
frontend/assets/css/boostrap/bootstrap-grid.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/css/boostrap/bootstrap-grid.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap-grid.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-grid.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/css/boostrap/bootstrap-grid.min.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap-grid.min.css.map
Normal file
File diff suppressed because one or more lines are too long
4084
frontend/assets/css/boostrap/bootstrap-grid.rtl.css
vendored
Normal file
4084
frontend/assets/css/boostrap/bootstrap-grid.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/css/boostrap/bootstrap-grid.rtl.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap-grid.rtl.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-grid.rtl.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-grid.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
597
frontend/assets/css/boostrap/bootstrap-reboot.css
vendored
Normal file
597
frontend/assets/css/boostrap/bootstrap-reboot.css
vendored
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rtl:raw:
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||||
1
frontend/assets/css/boostrap/bootstrap-reboot.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap-reboot.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-reboot.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-reboot.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
594
frontend/assets/css/boostrap/bootstrap-reboot.rtl.css
vendored
Normal file
594
frontend/assets/css/boostrap/bootstrap-reboot.rtl.css
vendored
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: right;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||||
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-reboot.rtl.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-reboot.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5402
frontend/assets/css/boostrap/bootstrap-utilities.css
vendored
Normal file
5402
frontend/assets/css/boostrap/bootstrap-utilities.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/css/boostrap/bootstrap-utilities.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap-utilities.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-utilities.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-utilities.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5393
frontend/assets/css/boostrap/bootstrap-utilities.rtl.css
vendored
Normal file
5393
frontend/assets/css/boostrap/bootstrap-utilities.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap-utilities.rtl.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap-utilities.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
12057
frontend/assets/css/boostrap/bootstrap.css
vendored
Normal file
12057
frontend/assets/css/boostrap/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/css/boostrap/bootstrap.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/css/boostrap/bootstrap.min.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
12030
frontend/assets/css/boostrap/bootstrap.rtl.css
vendored
Normal file
12030
frontend/assets/css/boostrap/bootstrap.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/css/boostrap/bootstrap.rtl.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap.rtl.css.map
Normal file
File diff suppressed because one or more lines are too long
6
frontend/assets/css/boostrap/bootstrap.rtl.min.css
vendored
Normal file
6
frontend/assets/css/boostrap/bootstrap.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/css/boostrap/bootstrap.rtl.min.css.map
Normal file
1
frontend/assets/css/boostrap/bootstrap.rtl.min.css.map
Normal file
File diff suppressed because one or more lines are too long
3
frontend/assets/css/bulma.min.css
vendored
Normal file
3
frontend/assets/css/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
frontend/assets/css/styles.css
Normal file
8
frontend/assets/css/styles.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
button.htmx-swapping {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#path-list .column{
|
||||||
|
padding-left: inherit;
|
||||||
|
}
|
||||||
6314
frontend/assets/js/bootstrap/bootstrap.bundle.js
vendored
Normal file
6314
frontend/assets/js/bootstrap/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/js/bootstrap/bootstrap.bundle.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
7
frontend/assets/js/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
7
frontend/assets/js/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/js/bootstrap/bootstrap.bundle.min.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
4447
frontend/assets/js/bootstrap/bootstrap.esm.js
vendored
Normal file
4447
frontend/assets/js/bootstrap/bootstrap.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/js/bootstrap/bootstrap.esm.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.esm.js.map
Normal file
File diff suppressed because one or more lines are too long
7
frontend/assets/js/bootstrap/bootstrap.esm.min.js
vendored
Normal file
7
frontend/assets/js/bootstrap/bootstrap.esm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/js/bootstrap/bootstrap.esm.min.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.esm.min.js.map
Normal file
File diff suppressed because one or more lines are too long
4494
frontend/assets/js/bootstrap/bootstrap.js
vendored
Normal file
4494
frontend/assets/js/bootstrap/bootstrap.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/assets/js/bootstrap/bootstrap.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.js.map
Normal file
File diff suppressed because one or more lines are too long
7
frontend/assets/js/bootstrap/bootstrap.min.js
vendored
Normal file
7
frontend/assets/js/bootstrap/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/assets/js/bootstrap/bootstrap.min.js.map
Normal file
1
frontend/assets/js/bootstrap/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
9
frontend/assets/js/function/functions.js
Normal file
9
frontend/assets/js/function/functions.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
function toggleMenu() {
|
||||||
|
const menu = document.getElementById('libraryMenu');
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenuGoDownload() {
|
||||||
|
const menu = document.getElementById('menuDownload');
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
}
|
||||||
13
frontend/assets/js/function/login.js
Normal file
13
frontend/assets/js/function/login.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
document.body.addEventListener('htmx:confirm', function(evt) {
|
||||||
|
// 0. To modify the behavior only for elements with the hx-confirm attribute,
|
||||||
|
// check if evt.detail.target.hasAttribute('hx-confirm')
|
||||||
|
|
||||||
|
// 1. Prevent the default behavior (this will prevent the request from being issued)
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
// 2. Do your own logic here
|
||||||
|
console.log(evt.detail)
|
||||||
|
|
||||||
|
// 3. Manually issue the request when you are ready
|
||||||
|
evt.detail.issueRequest(); // or evt.detail.issueRequest(true) to skip the built-in window.confirm()
|
||||||
|
});
|
||||||
5261
frontend/assets/js/htmx.js
Normal file
5261
frontend/assets/js/htmx.js
Normal file
File diff suppressed because it is too large
Load Diff
85
frontend/assets/js/index.js
Normal file
85
frontend/assets/js/index.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
console.log("la");
|
||||||
|
console.log("la");
|
||||||
|
async function validatePath() {
|
||||||
|
const pathInput = document.getElementById('path-input');
|
||||||
|
const statusIcon = document.getElementById('path-status-icon');
|
||||||
|
const validateBtn = document.getElementById('validate-btn');
|
||||||
|
|
||||||
|
const inputPath=document.getElementById('path');
|
||||||
|
const inputPathV=document.getElementById('namePath');
|
||||||
|
const path = pathInput.value;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
statusIcon.innerHTML = '<i class="fas fa-times has-text-danger"></i>';
|
||||||
|
validateBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusIcon.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i>'; // Loading icon
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/validate-path', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
statusIcon.innerHTML = '<i class="fas fa-check-square"></i>';
|
||||||
|
validateBtn.disabled = false;
|
||||||
|
inputPath.value=path;
|
||||||
|
inputPathV.style.display="block";
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
statusIcon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
|
||||||
|
validateBtn.disabled = true;
|
||||||
|
console.error('Error:', result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusIcon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
|
||||||
|
validateBtn.disabled = true;
|
||||||
|
console.error('Request failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function disableAllInputPath(id){
|
||||||
|
console.log(this)
|
||||||
|
var inputs = document.querySelectorAll('#path-'+id+' .fff');
|
||||||
|
var btn =document.getElementById('btn-path-annuler-'+id)
|
||||||
|
btn.style.display = "none";
|
||||||
|
var btn2 =document.getElementById('btn-path-edit-'+id)
|
||||||
|
btn2.style.display = "block";
|
||||||
|
var btn3 =document.getElementById('btn-path-valider-'+id)
|
||||||
|
btn3.style.display = "none";
|
||||||
|
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
input.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
function enableAllInputPath(id){
|
||||||
|
console.log(this)
|
||||||
|
var inputs = document.querySelectorAll('#path-'+id+' .fff');
|
||||||
|
var btn =document.getElementById('btn-path-annuler-'+id)
|
||||||
|
btn.style.display = "block";
|
||||||
|
var btn2 =document.getElementById('btn-path-edit-'+id)
|
||||||
|
btn2.style.display = "none";
|
||||||
|
var btn3 =document.getElementById('btn-path-valider-'+id)
|
||||||
|
btn3.style.display = "block";
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
input.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputHidden(target,value){
|
||||||
|
document.getElementById(target).value = value;
|
||||||
|
|
||||||
|
}
|
||||||
|
function hide(target){
|
||||||
|
var btn =document.getElementById(target)
|
||||||
|
btn.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("htmx:afterOnLoad", function (event) {
|
||||||
|
console.log("Réponse du serveur :", event.detail.xhr.responseText);
|
||||||
|
});
|
||||||
|
|
||||||
36
frontend/assets/js/json-enc.js
Normal file
36
frontend/assets/js/json-enc.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
(function() {
|
||||||
|
let api
|
||||||
|
htmx.defineExtension('json-enc', {
|
||||||
|
init: function(apiRef) {
|
||||||
|
api = apiRef
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
if (name === 'htmx:configRequest') {
|
||||||
|
evt.detail.headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
encodeParameters: function(xhr, parameters, elt) {
|
||||||
|
xhr.overrideMimeType('text/json')
|
||||||
|
|
||||||
|
const vals = api.getExpressionVars(elt)
|
||||||
|
const object = {}
|
||||||
|
parameters.forEach(function(value, key) {
|
||||||
|
// FormData encodes values as strings, restore hx-vals/hx-vars with their initial types
|
||||||
|
const typedValue = Object.hasOwn(vals, key) ? vals[key] : value
|
||||||
|
if (Object.hasOwn(object, key)) {
|
||||||
|
if (!Array.isArray(object[key])) {
|
||||||
|
object[key] = [object[key]]
|
||||||
|
}
|
||||||
|
object[key].push(typedValue)
|
||||||
|
} else {
|
||||||
|
object[key] = typedValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (JSON.stringify(object))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
291
frontend/assets/js/sse.js
Normal file
291
frontend/assets/js/sse.js
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
/*
|
||||||
|
Server Sent Events Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('sse', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init saves the provided reference to the internal HTMX API.
|
||||||
|
*
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} api
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// store a reference to the internal API.
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// set a function in the public API for creating new EventSource objects
|
||||||
|
if (htmx.createEventSource == undefined) {
|
||||||
|
htmx.createEventSource = createEventSource
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectors: function() {
|
||||||
|
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
if (source) {
|
||||||
|
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeReplaced',
|
||||||
|
})
|
||||||
|
internalData.sseEventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create EventSources when elements are processed
|
||||||
|
case 'htmx:afterProcessNode':
|
||||||
|
ensureEventSourceOnElement(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createEventSource is the default method for creating new EventSource objects.
|
||||||
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns EventSource
|
||||||
|
*/
|
||||||
|
function createEventSource(url) {
|
||||||
|
return new EventSource(url, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
|
* the closest event source
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function registerSSE(elt) {
|
||||||
|
// Add message handlers for every `sse-swap` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||||
|
var sseEventNames = sseSwapAttr.split(',')
|
||||||
|
|
||||||
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
|
const sseEventName = sseEventNames[i].trim()
|
||||||
|
const listener = function(event) {
|
||||||
|
// If the source is missing then close SSE
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap the response into the DOM and trigger a notification
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
swap(elt, event.data)
|
||||||
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(sseEventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener = function (event) {
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||||
|
}
|
||||||
|
// Trigger events to be handled by the rest of htmx
|
||||||
|
htmx.trigger(elt, ts.trigger, event)
|
||||||
|
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(ts.trigger.slice(4), listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
|
* is created and stored in the element's internalData.
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
|
*/
|
||||||
|
function ensureEventSourceOnElement(elt, retryCount) {
|
||||||
|
if (elt == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle extension source creation attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||||
|
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||||
|
if (sseURL == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEventSource(elt, sseURL, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSSE(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEventSource(elt, url, retryCount) {
|
||||||
|
var source = htmx.createEventSource(url)
|
||||||
|
|
||||||
|
source.onerror = function(err) {
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0
|
||||||
|
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||||
|
var timeout = retryCount * 500
|
||||||
|
window.setTimeout(function() {
|
||||||
|
ensureEventSourceOnElement(elt, retryCount)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||||
|
|
||||||
|
if (retryCount && retryCount > 0) {
|
||||||
|
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||||
|
for (let i = 0; i < childrenToFix.length; i++) {
|
||||||
|
registerSSE(childrenToFix[i])
|
||||||
|
}
|
||||||
|
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||||
|
retryCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source
|
||||||
|
|
||||||
|
|
||||||
|
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||||
|
if (closeAttribute) {
|
||||||
|
// close eventsource when this message is received
|
||||||
|
source.addEventListener(closeAttribute, function() {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'message',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseSSESource confirms that the parent element still exists.
|
||||||
|
* If not, then any associated SSE source is closed and the function returns true.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function maybeCloseSSESource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var source = api.getInternalData(elt).sseEventSource
|
||||||
|
if (source != undefined) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeMissing',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
// source = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
function swap(elt, content) {
|
||||||
|
api.withExtensions(elt, function(extension) {
|
||||||
|
content = extension.transformResponse(content, null, elt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var swapSpec = api.getSwapSpecification(elt)
|
||||||
|
var target = api.getTarget(elt)
|
||||||
|
api.swap(target, content, swapSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function hasEventSource(node) {
|
||||||
|
return api.getInternalData(node).sseEventSource != null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
476
frontend/assets/js/ws.js
Normal file
476
frontend/assets/js/ws.js
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
WebSockets Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api;
|
||||||
|
|
||||||
|
htmx.defineExtension("ws", {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* init is called once, when this extension is first registered.
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||||
|
*/
|
||||||
|
init: function (apiRef) {
|
||||||
|
|
||||||
|
// Store reference to internal API
|
||||||
|
api = apiRef;
|
||||||
|
|
||||||
|
// Default function for creating new EventSource objects
|
||||||
|
if (!htmx.createWebSocket) {
|
||||||
|
htmx.createWebSocket = createWebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default setting for reconnect delay
|
||||||
|
if (!htmx.config.wsReconnectDelay) {
|
||||||
|
htmx.config.wsReconnectDelay = "full-jitter";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
*/
|
||||||
|
onEvent: function (name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
|
||||||
|
// Try to close the socket when elements are removed
|
||||||
|
case "htmx:beforeCleanupElement":
|
||||||
|
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Try to create websockets when elements are processed
|
||||||
|
case "htmx:beforeProcessNode":
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||||
|
ensureWebSocket(child)
|
||||||
|
});
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||||
|
ensureWebSocketSend(child)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function splitOnWhitespace(trigger) {
|
||||||
|
return trigger.trim().split(/\s+/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyWebsocketURL(elt) {
|
||||||
|
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||||
|
if (legacySSEValue) {
|
||||||
|
var values = splitOnWhitespace(legacySSEValue);
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
var value = values[i].split(/:(.+)/);
|
||||||
|
if (value[0] === "connect") {
|
||||||
|
return value[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||||
|
* the element's "ws-connect" attribute.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function ensureWebSocket(socketElt) {
|
||||||
|
|
||||||
|
// If the element containing the WebSocket connection no longer exists, then
|
||||||
|
// do not connect/reconnect the WebSocket.
|
||||||
|
if (!api.bodyContains(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source straight from the element's value
|
||||||
|
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||||
|
|
||||||
|
if (wssSource == null || wssSource === "") {
|
||||||
|
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||||
|
if (legacySource == null) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
wssSource = legacySource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that the wssSource value is a fully qualified URL
|
||||||
|
if (wssSource.indexOf("/") === 0) {
|
||||||
|
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
wssSource = "wss://" + base_part + wssSource;
|
||||||
|
} else if (location.protocol === 'http:') {
|
||||||
|
wssSource = "ws://" + base_part + wssSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||||
|
return htmx.createWebSocket(wssSource)
|
||||||
|
});
|
||||||
|
|
||||||
|
socketWrapper.addEventListener('message', function (event) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = event.data;
|
||||||
|
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||||
|
message: response,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
})) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.withExtensions(socketElt, function (extension) {
|
||||||
|
response = extension.transformResponse(response, null, socketElt);
|
||||||
|
});
|
||||||
|
|
||||||
|
var settleInfo = api.makeSettleInfo(socketElt);
|
||||||
|
var fragment = api.makeFragment(response);
|
||||||
|
|
||||||
|
if (fragment.children.length) {
|
||||||
|
var children = Array.from(fragment.children);
|
||||||
|
for (var i = 0; i < children.length; i++) {
|
||||||
|
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.settleImmediately(settleInfo.tasks);
|
||||||
|
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Put the WebSocket into the HTML Element's custom data.
|
||||||
|
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} WebSocketWrapper
|
||||||
|
* @property {WebSocket} socket
|
||||||
|
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||||
|
* @property {number} retryCount
|
||||||
|
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||||
|
* @property {(message: string, sendElt: Element) => void} send
|
||||||
|
* @property {(event: string, handler: Function) => void} addEventListener
|
||||||
|
* @property {() => void} handleQueuedMessages
|
||||||
|
* @property {() => void} init
|
||||||
|
* @property {() => void} close
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param socketElt
|
||||||
|
* @param socketFunc
|
||||||
|
* @returns {WebSocketWrapper}
|
||||||
|
*/
|
||||||
|
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||||
|
var wrapper = {
|
||||||
|
socket: null,
|
||||||
|
messageQueue: [],
|
||||||
|
retryCount: 0,
|
||||||
|
|
||||||
|
/** @type {Object<string, Function[]>} */
|
||||||
|
events: {},
|
||||||
|
|
||||||
|
addEventListener: function (event, handler) {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.addEventListener(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events[event].push(handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendImmediately: function (message, sendElt) {
|
||||||
|
if (!this.socket) {
|
||||||
|
api.triggerErrorEvent()
|
||||||
|
}
|
||||||
|
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||||
|
message: message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})) {
|
||||||
|
this.socket.send(message);
|
||||||
|
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||||
|
message: message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
send: function (message, sendElt) {
|
||||||
|
if (this.socket.readyState !== this.socket.OPEN) {
|
||||||
|
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||||
|
} else {
|
||||||
|
this.sendImmediately(message, sendElt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleQueuedMessages: function () {
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
var queuedItem = this.messageQueue[0]
|
||||||
|
if (this.socket.readyState === this.socket.OPEN) {
|
||||||
|
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||||
|
this.messageQueue.shift();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||||
|
// Close discarded socket
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new WebSocket and event handlers
|
||||||
|
/** @type {WebSocket} */
|
||||||
|
var socket = socketFunc();
|
||||||
|
|
||||||
|
// The event.type detail is added for interface conformance with the
|
||||||
|
// other two lifecycle events (open and close) so a single handler method
|
||||||
|
// can handle them polymorphically, if required.
|
||||||
|
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||||
|
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
socket.onopen = function (e) {
|
||||||
|
wrapper.retryCount = 0;
|
||||||
|
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||||
|
wrapper.handleQueuedMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = function (e) {
|
||||||
|
// If socket should not be connected, stop further attempts to establish connection
|
||||||
|
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||||
|
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||||
|
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||||
|
setTimeout(function () {
|
||||||
|
wrapper.retryCount += 1;
|
||||||
|
wrapper.init();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||||
|
// to determine whether closure has been valid or abnormal
|
||||||
|
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function (e) {
|
||||||
|
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||||
|
maybeCloseWebSocketSource(socketElt);
|
||||||
|
};
|
||||||
|
|
||||||
|
var events = this.events;
|
||||||
|
Object.keys(events).forEach(function (k) {
|
||||||
|
events[k].forEach(function (e) {
|
||||||
|
socket.addEventListener(k, e);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
close: function () {
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.init();
|
||||||
|
|
||||||
|
wrapper.publicInterface = {
|
||||||
|
send: wrapper.send.bind(wrapper),
|
||||||
|
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||||
|
queue: wrapper.messageQueue
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocketSend attaches trigger handles to elements with
|
||||||
|
* "ws-send" attribute
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function ensureWebSocketSend(elt) {
|
||||||
|
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||||
|
processWebSocketSend(webSocketParent, elt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hasWebSocket function checks if a node has webSocket instance attached
|
||||||
|
* @param {HTMLElement} node
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasWebSocket(node) {
|
||||||
|
return api.getInternalData(node).webSocket != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* processWebSocketSend adds event listeners to the <form> element so that
|
||||||
|
* messages can be sent to the WebSocket server when the form is submitted.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @param {HTMLElement} sendElt
|
||||||
|
*/
|
||||||
|
function processWebSocketSend(socketElt, sendElt) {
|
||||||
|
var nodeData = api.getInternalData(sendElt);
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||||
|
triggerSpecs.forEach(function (ts) {
|
||||||
|
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {WebSocketWrapper} */
|
||||||
|
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||||
|
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||||
|
var results = api.getInputValues(sendElt, 'post');
|
||||||
|
var errors = results.errors;
|
||||||
|
var rawParameters = results.values;
|
||||||
|
var expressionVars = api.getExpressionVars(sendElt);
|
||||||
|
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||||
|
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||||
|
|
||||||
|
var sendConfig = {
|
||||||
|
parameters: filteredParameters,
|
||||||
|
unfilteredParameters: allParameters,
|
||||||
|
headers: headers,
|
||||||
|
errors: errors,
|
||||||
|
|
||||||
|
triggeringEvent: evt,
|
||||||
|
messageBody: undefined,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = sendConfig.messageBody;
|
||||||
|
if (body === undefined) {
|
||||||
|
var toSend = Object.assign({}, sendConfig.parameters);
|
||||||
|
if (sendConfig.headers)
|
||||||
|
toSend['HEADERS'] = headers;
|
||||||
|
body = JSON.stringify(toSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
socketWrapper.send(body, elt);
|
||||||
|
|
||||||
|
if (evt && api.shouldCancel(evt, elt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||||
|
* @param {number} retryCount // The number of retries that have already taken place
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getWebSocketReconnectDelay(retryCount) {
|
||||||
|
|
||||||
|
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||||
|
var delay = htmx.config.wsReconnectDelay;
|
||||||
|
if (typeof delay === 'function') {
|
||||||
|
return delay(retryCount);
|
||||||
|
}
|
||||||
|
if (delay === 'full-jitter') {
|
||||||
|
var exp = Math.min(retryCount, 6);
|
||||||
|
var maxDelay = 1000 * Math.pow(2, exp);
|
||||||
|
return maxDelay * Math.random();
|
||||||
|
}
|
||||||
|
|
||||||
|
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||||
|
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||||
|
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||||
|
* returns FALSE.
|
||||||
|
*
|
||||||
|
* @param {*} elt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function maybeCloseWebSocketSource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
api.getInternalData(elt).webSocket.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createWebSocket is the default method for creating new WebSocket objects.
|
||||||
|
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns WebSocket
|
||||||
|
*/
|
||||||
|
function createWebSocket(url) {
|
||||||
|
var sock = new WebSocket(url, []);
|
||||||
|
sock.binaryType = htmx.config.wsBinaryType;
|
||||||
|
return sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} attributeName
|
||||||
|
*/
|
||||||
|
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||||
|
|
||||||
|
var result = []
|
||||||
|
|
||||||
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||||
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||||
|
result.push(elt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search all child nodes that match the requested attribute
|
||||||
|
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||||
|
result.push(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {T[]} arr
|
||||||
|
* @param {(T) => void} func
|
||||||
|
*/
|
||||||
|
function forEach(arr, func) {
|
||||||
|
if (arr) {
|
||||||
|
for (var i = 0; i < arr.length; i++) {
|
||||||
|
func(arr[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
37
frontend/templates/admin_conversations.pages.tmpl
Normal file
37
frontend/templates/admin_conversations.pages.tmpl
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!-- admin_conversations.pages.tmpl -->
|
||||||
|
{{ define "admin_conversations.pages.tmpl" }}
|
||||||
|
<h2>Historique des messages de l'utilisateur #{{ .UserID }}</h2>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Filtrer par type :</label>
|
||||||
|
<select id="filterType" name="type" hx-get="/api/user/{{ .UserID }}/conversations" hx-target="#conversationRows" hx-trigger="change" hx-include="#filterType">
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="text">Texte</option>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="video">Vidéo</option>
|
||||||
|
<option value="interactive">Interactif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th>Expéditeur</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Contenu</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="conversationRows"
|
||||||
|
hx-get="/api/user/{{ .UserID }}/conversations"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<hr>
|
||||||
|
<h3>Conversation complète</h3>
|
||||||
|
<div id="threadViewer" class="mt-3"></div>
|
||||||
|
{{ end }}
|
||||||
15
frontend/templates/admin_conversations_rows.pages.tmpl
Normal file
15
frontend/templates/admin_conversations_rows.pages.tmpl
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{ define "admin_conversations_rows.pages.tmpl" }}
|
||||||
|
{{ range .Conversations }}
|
||||||
|
<tr hx-get="/admin/user/{{ $.UserID }}/conversation-thread"
|
||||||
|
hx-target="#threadViewer"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<td>{{ .CreatedAt.Format "2006-01-02 15:04:05" }}</td>
|
||||||
|
<td>{{ .Direction }}</td>
|
||||||
|
<td>{{ .From }}</td>
|
||||||
|
<td>{{ .Type }}</td>
|
||||||
|
<td>{{ .Content }}</td>
|
||||||
|
<td>{{ .Status }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
45
frontend/templates/admin_conversations_thread.pages.tmpl
Normal file
45
frontend/templates/admin_conversations_thread.pages.tmpl
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{{ define "admin_conversations_thread.pages.tmpl" }}
|
||||||
|
<div class="chat-thread">
|
||||||
|
{{ range .Conversations }}
|
||||||
|
<div class="bubble {{ if eq .Direction "inbound" }}left{{ else }}right{{ end }}">
|
||||||
|
<div class="meta">
|
||||||
|
<small><strong>{{ .From }}</strong> — {{ .CreatedAt.Format "2006-01-02 15:04:05" }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{ .Content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.chat-thread {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
max-width: 75%;
|
||||||
|
clear: both;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.bubble.left {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
.bubble.right {
|
||||||
|
background-color: #d1e7dd;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
53
frontend/templates/admin_user_edit.pages.tmpl
Normal file
53
frontend/templates/admin_user_edit.pages.tmpl
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{{ define "admin_user_edit.pages.tmpl" }}
|
||||||
|
<h2 class="title is-4">Modifier l'utilisateur #{{ .User.ID }}</h2>
|
||||||
|
|
||||||
|
<form hx-put="/api/user/update/{{ .User.ID }}" hx-target="#userList" hx-swap="outerHTML" class="box">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Email</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="email" name="email" value="{{ .User.Email }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Mot de passe (laisser vide pour ne pas changer)</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="password" name="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Role</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select name="role">
|
||||||
|
<option value="CLIENT" {{ if eq .User.Role "CLIENT" }}selected{{ end }}>Client</option>
|
||||||
|
<option value="ADMIN" {{ if eq .User.Role "ADMIN" }}selected{{ end }}>Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="is_active" {{ if .User.IsActive }}checked{{ end }}> Actif
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Token WhatsApp</label>
|
||||||
|
<input class="input" name="whatsapp_token" type="text" value="{{ .User.WhatsappToken }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Phone Number ID</label>
|
||||||
|
<input class="input" name="whatsapp_phone_number_id" type="text" value="{{ .User.WhatsappPhoneNumberID }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-success" type="submit">💾 Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
37
frontend/templates/admin_users.pages.tmpl
Normal file
37
frontend/templates/admin_users.pages.tmpl
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{{ define "admin_users.pages.tmpl" }}
|
||||||
|
<h1 class="title">Gestion des utilisateurs</h1>
|
||||||
|
<button class="button is-primary"
|
||||||
|
hx-get="/admin/user/new"
|
||||||
|
hx-target="#editForm"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
➕ Créer un utilisateur
|
||||||
|
</button>
|
||||||
|
<table class="table is-fullwidth is-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Actif</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="userList">
|
||||||
|
{{ range .Users }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .ID }}</td>
|
||||||
|
<td>{{ .Email }}</td>
|
||||||
|
<td>{{ .Role }}</td>
|
||||||
|
<td>{{ if .IsActive }}✅{{ else }}❌{{ end }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="button is-small is-info" hx-get="/admin/user/{{ .ID }}/edit" hx-target="#editForm" hx-swap="innerHTML">✏️ Modifier</button>
|
||||||
|
<button class="button is-small is-danger" hx-delete="/api/user/delete/{{ .ID }}" hx-confirm="Confirmer suppression ?" hx-target="#userList" hx-swap="outerHTML">🗑️ Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div id="editForm"></div>
|
||||||
|
{{ end }}
|
||||||
77
frontend/templates/dashboard.pages.tmpl
Normal file
77
frontend/templates/dashboard.pages.tmpl
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{{ define "dashboard.pages.tmpl" }}
|
||||||
|
<div class="columns">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="menu column is-2">
|
||||||
|
<p class="menu-label">Navigation</p>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a href="/dashboard">🏠 Dashboard</a></li>
|
||||||
|
{{ if eq .User.Role "ADMIN" }}
|
||||||
|
<li><a href="/admin/users">👤 Utilisateurs</a></li>
|
||||||
|
<li><a href="/test/send">📤 Test envoi</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="column is-10">
|
||||||
|
<h1 class="title">Tableau de bord</h1>
|
||||||
|
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="box has-background-light">
|
||||||
|
<p class="title is-5">👥 Total utilisateurs</p>
|
||||||
|
<p class="subtitle is-4">{{ .Stats.TotalUsers }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="box has-background-light">
|
||||||
|
<p class="title is-5">📈 Crédits consommés</p>
|
||||||
|
<p class="subtitle is-4">{{ .Stats.TotalCreditsUsed }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="box has-background-light">
|
||||||
|
<p class="title is-5">💬 Conversations</p>
|
||||||
|
<p class="subtitle is-4">{{ .Stats.TotalConversations }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="title is-5">Dernières conversations</h2>
|
||||||
|
<div class="field is-grouped is-grouped-multiline">
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="/dashboard?filter=today">📅 Aujourd'hui</a>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="/dashboard?filter=week">📆 Cette semaine</a>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="/dashboard">🔁 Tout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table is-fullwidth is-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>De</th>
|
||||||
|
<th>À</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Contenu</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Conversations }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .CreatedAt.Format "02/01/2006 15:04" }}</td>
|
||||||
|
<td>{{ .From }}</td>
|
||||||
|
<td>{{ .To }}</td>
|
||||||
|
<td>{{ .Type }}</td>
|
||||||
|
<td>{{ .Content }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
26
frontend/templates/head.pages.tmpl
Normal file
26
frontend/templates/head.pages.tmpl
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
{{ define "head" }}
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/frontend/assets/css/bulma.min.css">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/frontend/assets/css/styles.css">
|
||||||
|
<script src="/frontend/assets/js/htmx.js" ></script>
|
||||||
|
<script src="/frontend/assets/js/sse.js"></script>
|
||||||
|
<script src="/frontend/assets/js/index.js" ></script>
|
||||||
|
<script src="/frontend/assets/js/json-enc.js"></script>
|
||||||
|
<script src="/frontend/assets/js/ws.js"></script>
|
||||||
|
<script src="/frontend/assets/js/function/functions.js"></script>
|
||||||
|
<title>Login</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
45
frontend/templates/jwt.pages.html
Normal file
45
frontend/templates/jwt.pages.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Générateur JWT (test WhatsApp)</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jsonwebtoken@9.0.2/index.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function generateJWT() {
|
||||||
|
const ssoid = document.getElementById("ssoid").value;
|
||||||
|
const secret = document.getElementById("secret").value;
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1h
|
||||||
|
const payload = {
|
||||||
|
username: ssoid,
|
||||||
|
exp: exp
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = window.jwt.sign(payload, secret);
|
||||||
|
document.getElementById("output").textContent = token;
|
||||||
|
document.getElementById("copyBtn").disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
const token = document.getElementById("output").textContent;
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
alert("Token copié dans le presse-papier ✅");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Générateur JWT de test</h1>
|
||||||
|
<label>SSOID (username dans token):</label><br>
|
||||||
|
<input type="text" id="ssoid" value="admin001" size="30"><br><br>
|
||||||
|
|
||||||
|
<label>Clé secrète :</label><br>
|
||||||
|
<input type="text" id="secret" value="secret-key" size="30"><br><br>
|
||||||
|
|
||||||
|
<button onclick="generateJWT()">Générer le token JWT</button>
|
||||||
|
|
||||||
|
<h2>Token :</h2>
|
||||||
|
<pre id="output" style="white-space: pre-wrap; background: #f9f9f9; border: 1px solid #ccc; padding: 1rem;"></pre>
|
||||||
|
|
||||||
|
<button id="copyBtn" onclick="copyToken()" disabled>📋 Copier le token</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
frontend/templates/login.pages.tmpl
Normal file
45
frontend/templates/login.pages.tmpl
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
{{ template "head" . }}
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="hero is-fullheight is-flex is-justify-content-center is-align-items-center">
|
||||||
|
<div class="card" style="width: 400px;">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<h1 >Login Panel</p>
|
||||||
|
</div>
|
||||||
|
<form >
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="formGroupExampleInput" class="form-label">Email</label>
|
||||||
|
<input type="text" class="input" id="email" placeholder="Votre email" name="email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="formGroupExampleInput2" class="form-label">Password</label>
|
||||||
|
<input type="password" class="input" id="password" placeholder="Password" name="password">
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<button class="button is-primary is-outlined" type="submit" hx-post="/api/login"
|
||||||
|
hx-ext="json-enc">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
<script src="/frontend/assets/js/function/login.js"></script>
|
||||||
|
|
||||||
|
</html>
|
||||||
117
frontend/templates/test.pages.tmpl
Normal file
117
frontend/templates/test.pages.tmpl
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Test Envoi WhatsApp</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 2rem; }
|
||||||
|
fieldset { margin-bottom: 2rem; padding: 1rem; border: 1px solid #ccc; }
|
||||||
|
legend { font-weight: bold; }
|
||||||
|
label { display: block; margin-top: 0.5rem; }
|
||||||
|
textarea { width: 100%; height: 4rem; }
|
||||||
|
input[type="text"] { width: 100%; }
|
||||||
|
button { margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Tests d'envoi WhatsApp (JWT requis)</h1>
|
||||||
|
|
||||||
|
<!-- Bloc 1 : message texte simple -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>1. Message texte simple</legend>
|
||||||
|
<label>Numéro (E.164) :</label>
|
||||||
|
<input type="text" id="to1" value="33600000000">
|
||||||
|
<label>Message :</label>
|
||||||
|
<textarea id="text1">Bonjour depuis le test simple !</textarea>
|
||||||
|
<button onclick="sendText()">Envoyer</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Bloc 2 : message texte + média -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>2. Message image + texte</legend>
|
||||||
|
<label>Numéro :</label>
|
||||||
|
<input type="text" id="to2" value="33600000000">
|
||||||
|
<label>ID média :</label>
|
||||||
|
<input type="text" id="media2" placeholder="media_id">
|
||||||
|
<label>Légende :</label>
|
||||||
|
<input type="text" id="caption2" value="Voici une image !">
|
||||||
|
<button onclick="sendImage()">Envoyer</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Bloc 3 : message interactif -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>3. Message interactif (image + bouton URL)</legend>
|
||||||
|
<label>Numéro :</label>
|
||||||
|
<input type="text" id="to3" value="33600000000">
|
||||||
|
<label>Texte :</label>
|
||||||
|
<input type="text" id="text3" value="Cliquez ci-dessous pour en savoir plus">
|
||||||
|
<label>Image (ID média) :</label>
|
||||||
|
<input type="text" id="img3" placeholder="media_id">
|
||||||
|
<label>Titre :</label>
|
||||||
|
<input type="text" id="title3" value="Promo spéciale">
|
||||||
|
<label>URL 1 :</label>
|
||||||
|
<input type="text" id="url1" value="https://example.com">
|
||||||
|
<label>URL 2 :</label>
|
||||||
|
<input type="text" id="url2" value="https://google.com">
|
||||||
|
<button onclick="sendInteractive()">Envoyer</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h2>Réponse :</h2>
|
||||||
|
<pre id="response" style="background:#f4f4f4; border:1px solid #ccc; padding:1rem;"></pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function send(payload) {
|
||||||
|
const res = await fetch('/api/message/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const txt = await res.text();
|
||||||
|
document.getElementById('response').textContent = txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendText() {
|
||||||
|
const to = document.getElementById('to1').value;
|
||||||
|
const text = document.getElementById('text1').value;
|
||||||
|
send({ to, type: 'text', text: { body: text } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendImage() {
|
||||||
|
const to = document.getElementById('to2').value;
|
||||||
|
const mediaID = document.getElementById('media2').value;
|
||||||
|
const caption = document.getElementById('caption2').value;
|
||||||
|
send({ to, type: 'image', image: { id: mediaID, caption: caption } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInteractive() {
|
||||||
|
const to = document.getElementById('to3').value;
|
||||||
|
const body = document.getElementById('text3').value;
|
||||||
|
const imageID = document.getElementById('img3').value;
|
||||||
|
const title = document.getElementById('title3').value;
|
||||||
|
const url1 = document.getElementById('url1').value;
|
||||||
|
const url2 = document.getElementById('url2').value;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
to,
|
||||||
|
type: 'interactive',
|
||||||
|
interactive: {
|
||||||
|
type: 'button',
|
||||||
|
body: { text: body },
|
||||||
|
header: { type: 'image', image: { id: imageID } },
|
||||||
|
footer: { text: title },
|
||||||
|
action: {
|
||||||
|
buttons: [
|
||||||
|
{ type: 'url', url: url1, text: 'Visiter 1' },
|
||||||
|
{ type: 'url', url: url2, text: 'Visiter 2' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
send(payload);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
go.mod
Normal file
29
go.mod
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
module cangui/whatsapp
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
gorm.io/gorm v1.26.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
|
)
|
||||||
38
go.sum
Normal file
38
go.sum
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||||
|
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
93
note.txt
Normal file
93
note.txt
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# 📘 Documentation Admin – Intégration WhatsApp Business API
|
||||||
|
|
||||||
|
## 🔐 Configuration de base
|
||||||
|
|
||||||
|
### `.env` requis
|
||||||
|
```env
|
||||||
|
WHATSAPP_TOKEN=EAA...
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=1234567890
|
||||||
|
WHATSAPP_WEBHOOK_TOKEN=secrettoken
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification du webhook (Meta)
|
||||||
|
- URL : `https://yourdomain/api/whatsapp/webhook`
|
||||||
|
- Token de vérification : doit correspondre à `WHATSAPP_WEBHOOK_TOKEN`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📩 Envoi de message
|
||||||
|
|
||||||
|
**Route :** `POST /api/message/send`
|
||||||
|
|
||||||
|
### Exemple JSON (texte)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"to": "33612345678",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"body": "Bonjour 👋"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Supporte aussi `image`, `video`, `document`, `interactive`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Réception de messages / statuts
|
||||||
|
|
||||||
|
- Le webhook `POST /api/whatsapp/webhook` :
|
||||||
|
- enregistre les messages reçus dans la table `conversations`
|
||||||
|
- décrémente les crédits `CurrentMonthCredits`
|
||||||
|
- met à jour le `status` d'un message (`delivered`, `read`, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧾 Historique utilisateur
|
||||||
|
|
||||||
|
### Interface admin
|
||||||
|
- URL : `/admin/user/{id}/conversations`
|
||||||
|
- Permet :
|
||||||
|
- de visualiser tous les messages avec filtres
|
||||||
|
- de voir la conversation complète au clic sur une ligne (HTMX)
|
||||||
|
|
||||||
|
### API :
|
||||||
|
`GET /api/user/{id}/conversations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Modèle de conversation
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Conversation struct {
|
||||||
|
UserID uint
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
MessageID string
|
||||||
|
Type string
|
||||||
|
Content string
|
||||||
|
Direction string // inbound / outbound
|
||||||
|
Status string // delivered, read, sent...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧮 Crédit consommé par message
|
||||||
|
|
||||||
|
| Type | Crédit |
|
||||||
|
|--------------|--------|
|
||||||
|
| text | 1 |
|
||||||
|
| image/video | 2 |
|
||||||
|
| document | 2 |
|
||||||
|
| interactive | 1 |
|
||||||
|
| sticker/audio| 1 |
|
||||||
|
| reaction | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ À tester
|
||||||
|
- [ ] Vérifier réception via webhook
|
||||||
|
- [ ] Vérifier affichage des conversations admin
|
||||||
|
- [ ] Vérifier décompte de crédits
|
||||||
|
- [ ] Tester `GET` de validation webhook avec Meta
|
||||||
Loading…
Reference in New Issue
Block a user