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