chore: update
This commit is contained in:
@@ -15,7 +15,6 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/db"
|
"github.com/omnex/control-plane/api/internal/db"
|
||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
"github.com/omnex/control-plane/api/internal/k8s"
|
"github.com/omnex/control-plane/api/internal/k8s"
|
||||||
"github.com/omnex/control-plane/api/internal/leads"
|
|
||||||
"github.com/omnex/control-plane/api/internal/profile"
|
"github.com/omnex/control-plane/api/internal/profile"
|
||||||
"github.com/omnex/control-plane/api/internal/router"
|
"github.com/omnex/control-plane/api/internal/router"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
@@ -64,7 +63,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userStore auth.UserStore
|
var userStore auth.UserStore
|
||||||
var leadStore leads.Store
|
|
||||||
var demoStore demos.Store
|
var demoStore demos.Store
|
||||||
var demoPool demos.Pool
|
var demoPool demos.Pool
|
||||||
var codeStore sub.Store
|
var codeStore sub.Store
|
||||||
@@ -84,7 +82,6 @@ func main() {
|
|||||||
seedAdmin(users)
|
seedAdmin(users)
|
||||||
userStore = users
|
userStore = users
|
||||||
codeStore = sub.NewGormStore(gdb)
|
codeStore = sub.NewGormStore(gdb)
|
||||||
leadStore = leads.NewGormStore(gdb)
|
|
||||||
demoStore = demos.NewGormStore(gdb)
|
demoStore = demos.NewGormStore(gdb)
|
||||||
demoPool = demos.NewGormPool(gdb)
|
demoPool = demos.NewGormPool(gdb)
|
||||||
profileStore = profile.NewGormStore(gdb)
|
profileStore = profile.NewGormStore(gdb)
|
||||||
@@ -92,7 +89,6 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
userStore = seedMemUsers()
|
userStore = seedMemUsers()
|
||||||
codeStore = sub.NewMemStore()
|
codeStore = sub.NewMemStore()
|
||||||
leadStore = leads.NewMemStore()
|
|
||||||
demoStore = demos.NewMemStore()
|
demoStore = demos.NewMemStore()
|
||||||
demoPool = demos.NewMemPool()
|
demoPool = demos.NewMemPool()
|
||||||
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
|
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
|
||||||
@@ -104,8 +100,8 @@ func main() {
|
|||||||
log.Fatalf("k8s client: %v", err)
|
log.Fatalf("k8s client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config REST brute — nécessaire pour l'exec dans les pods (pg_dump /
|
// Config REST brute — nécessaire pour l'exec dans les pods (création du
|
||||||
// restore lors du passage d'une démo en abonnement payant).
|
// compte admin de la démo via psql une fois le backend démarré).
|
||||||
restConfig, err := k8s.NewRESTConfig(&cfg)
|
restConfig, err := k8s.NewRESTConfig(&cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("k8s rest config: %v", err)
|
log.Fatalf("k8s rest config: %v", err)
|
||||||
@@ -150,7 +146,6 @@ func main() {
|
|||||||
Issuer: iss,
|
Issuer: iss,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
|
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
|
||||||
LeadsH: leads.NewHandler(leadStore),
|
|
||||||
DemosH: demos.NewHandler(demoSvc, helmProv),
|
DemosH: demos.NewHandler(demoSvc, helmProv),
|
||||||
SubH: sub.NewHandler(codeStore, demoSvc),
|
SubH: sub.NewHandler(codeStore, demoSvc),
|
||||||
ProfileH: profile.NewHandler(profileStore),
|
ProfileH: profile.NewHandler(profileStore),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/omnex/control-plane/api/internal/auth"
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
"github.com/omnex/control-plane/api/internal/leads"
|
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ func AutoMigrate(gdb *gorm.DB) error {
|
|||||||
return gdb.AutoMigrate(
|
return gdb.AutoMigrate(
|
||||||
&auth.User{},
|
&auth.User{},
|
||||||
&sub.CodeBuySub{},
|
&sub.CodeBuySub{},
|
||||||
&leads.Lead{},
|
|
||||||
&demos.Demo{},
|
&demos.Demo{},
|
||||||
&demos.ExternalResource{},
|
&demos.ExternalResource{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func NewHandler(svc *Service, helm *HelmProvisioner) *Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
LeadID string `json:"lead_id" binding:"omitempty,uuid4"`
|
|
||||||
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
|
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
|
||||||
|
|
||||||
// Réglages saisis dans le popup de déploiement (voir ProvisionConfig).
|
// Réglages saisis dans le popup de déploiement (voir ProvisionConfig).
|
||||||
@@ -85,7 +84,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
AdminUsername: req.AdminUsername,
|
AdminUsername: req.AdminUsername,
|
||||||
AdminPassword: req.AdminPassword,
|
AdminPassword: req.AdminPassword,
|
||||||
}
|
}
|
||||||
d, err := h.svc.Create(req.LeadID, req.Username, cfg)
|
d, err := h.svc.Create(req.Username, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrPoolExhausted):
|
case errors.Is(err, ErrPoolExhausted):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import "time"
|
|||||||
type Demo struct {
|
type Demo struct {
|
||||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
|
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
|
||||||
LeadID string `gorm:"type:varchar(36);index" json:"lead_id,omitempty"`
|
|
||||||
Status Status `gorm:"size:20;not null;index" json:"status"`
|
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||||
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
||||||
URL string `gorm:"size:255" json:"url"`
|
URL string `gorm:"size:255" json:"url"`
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
|
|||||||
// username (optionnel) rattache la démo à un client existant. Un client n'a
|
// username (optionnel) rattache la démo à un client existant. Un client n'a
|
||||||
// droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage)
|
// droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage)
|
||||||
// n'est jamais persisté : transmis tel quel au provisioning.
|
// n'est jamais persisté : transmis tel quel au provisioning.
|
||||||
func (s *Service) Create(leadID, username string, cfg ProvisionConfig) (Demo, error) {
|
func (s *Service) Create(username string, cfg ProvisionConfig) (Demo, error) {
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Demo{}, err
|
return Demo{}, err
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,6 @@ func (s *Service) Create(leadID, username string, cfg ProvisionConfig) (Demo, er
|
|||||||
demo := Demo{
|
demo := Demo{
|
||||||
ID: id,
|
ID: id,
|
||||||
Username: normalizedUsername,
|
Username: normalizedUsername,
|
||||||
LeadID: leadID,
|
|
||||||
Status: StatusPending,
|
Status: StatusPending,
|
||||||
Namespace: "demo-" + shortID(id),
|
Namespace: "demo-" + shortID(id),
|
||||||
CreatedAt: s.now().UTC(),
|
CreatedAt: s.now().UTC(),
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package leads
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GormStore : Store adossé à PostgreSQL via GORM.
|
|
||||||
type GormStore struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGormStore(db *gorm.DB) *GormStore {
|
|
||||||
return &GormStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GormStore) Create(l Lead) (Lead, error) {
|
|
||||||
l.ID = uuid.NewString()
|
|
||||||
l.Status = StatusNew
|
|
||||||
l.CreatedAt = time.Now().UTC()
|
|
||||||
if err := s.db.Create(&l).Error; err != nil {
|
|
||||||
return Lead{}, err
|
|
||||||
}
|
|
||||||
return l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GormStore) List() ([]Lead, error) {
|
|
||||||
var out []Lead
|
|
||||||
if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GormStore) Get(id string) (Lead, bool) {
|
|
||||||
var l Lead
|
|
||||||
err := s.db.Where("id = ?", id).First(&l).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
|
|
||||||
return Lead{}, false
|
|
||||||
}
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GormStore) SetStatus(id string, status Status) (Lead, bool) {
|
|
||||||
var l Lead
|
|
||||||
if err := s.db.Where("id = ?", id).First(&l).Error; err != nil {
|
|
||||||
return Lead{}, false
|
|
||||||
}
|
|
||||||
l.Status = status
|
|
||||||
if err := s.db.Save(&l).Error; err != nil {
|
|
||||||
return Lead{}, false
|
|
||||||
}
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package leads
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
store Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(store Store) *Handler {
|
|
||||||
return &Handler{store: store}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRequest : entrée publique (formulaire vitrine). Validation stricte.
|
|
||||||
type createRequest struct {
|
|
||||||
Telegram string `json:"telegram" binding:"required,min=2,max=120"`
|
|
||||||
Message string `json:"message" binding:"max=2000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create enregistre un lead depuis le formulaire public.
|
|
||||||
// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
|
|
||||||
func (h *Handler) Create(c *gin.Context) {
|
|
||||||
var req createRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lead := Lead{
|
|
||||||
Telegram: sanitize(req.Telegram),
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := sanitize(req.Message); msg != "" {
|
|
||||||
lead.Message = &msg
|
|
||||||
}
|
|
||||||
|
|
||||||
l, err := h.store.Create(lead)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, l)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List renvoie tous les leads (back-office, protégé).
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
|
||||||
items, err := h.store.List()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusRequest struct {
|
|
||||||
Status Status `json:"status" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStatus met à jour le statut d'un lead (back-office, protégé).
|
|
||||||
func (h *Handler) SetStatus(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
var req statusRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || !req.Status.Valid() {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "statut invalide"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l, ok := h.store.SetStatus(id, req.Status)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lead introuvable"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, l)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitize : trim + échappement HTML pour neutraliser le XSS stocké.
|
|
||||||
func sanitize(s string) string {
|
|
||||||
return html.EscapeString(strings.TrimSpace(s))
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Package leads : gestion des prospects (feature "leads" du back-office).
|
|
||||||
package leads
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusNew Status = "new"
|
|
||||||
StatusContacted Status = "contacted"
|
|
||||||
StatusDemo Status = "demo"
|
|
||||||
StatusWon Status = "won"
|
|
||||||
StatusLost Status = "lost"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s Status) Valid() bool {
|
|
||||||
switch s {
|
|
||||||
case StatusNew, StatusContacted, StatusDemo, StatusWon, StatusLost:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lead : prospect. Modèle GORM.
|
|
||||||
type Lead struct {
|
|
||||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
|
||||||
Telegram string `gorm:"size:120;not null" json:"telegram"`
|
|
||||||
Message *string `gorm:"size:2000" json:"message"`
|
|
||||||
Status Status `gorm:"size:20;not null;index" json:"status"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName force le nom de table.
|
|
||||||
func (Lead) TableName() string { return "leads" }
|
|
||||||
|
|
||||||
// Store : persistance des leads. Impl mémoire ici, Postgres plus tard.
|
|
||||||
type Store interface {
|
|
||||||
Create(l Lead) (Lead, error)
|
|
||||||
List() ([]Lead, error)
|
|
||||||
Get(id string) (Lead, bool)
|
|
||||||
SetStatus(id string, s Status) (Lead, bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemStore : implémentation en mémoire (dev/tests).
|
|
||||||
type MemStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
items map[string]Lead
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMemStore() *MemStore {
|
|
||||||
return &MemStore{items: make(map[string]Lead)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemStore) Create(l Lead) (Lead, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
l.ID = uuid.NewString()
|
|
||||||
l.Status = StatusNew
|
|
||||||
l.CreatedAt = time.Now().UTC()
|
|
||||||
m.items[l.ID] = l
|
|
||||||
return l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemStore) List() ([]Lead, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
out := make([]Lead, 0, len(m.items))
|
|
||||||
for _, l := range m.items {
|
|
||||||
out = append(out, l)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemStore) Get(id string) (Lead, bool) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
l, ok := m.items[id]
|
|
||||||
return l, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemStore) SetStatus(id string, s Status) (Lead, bool) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
l, ok := m.items[id]
|
|
||||||
if !ok {
|
|
||||||
return Lead{}, false
|
|
||||||
}
|
|
||||||
l.Status = s
|
|
||||||
m.items[id] = l
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/config"
|
"github.com/omnex/control-plane/api/internal/config"
|
||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
"github.com/omnex/control-plane/api/internal/httpx"
|
"github.com/omnex/control-plane/api/internal/httpx"
|
||||||
"github.com/omnex/control-plane/api/internal/leads"
|
|
||||||
"github.com/omnex/control-plane/api/internal/profile"
|
"github.com/omnex/control-plane/api/internal/profile"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
@@ -22,7 +21,6 @@ type Deps struct {
|
|||||||
Issuer *auth.Issuer
|
Issuer *auth.Issuer
|
||||||
Sessions session.Manager
|
Sessions session.Manager
|
||||||
AuthH *auth.Handler
|
AuthH *auth.Handler
|
||||||
LeadsH *leads.Handler
|
|
||||||
DemosH *demos.Handler
|
DemosH *demos.Handler
|
||||||
SubH *sub.Handler
|
SubH *sub.Handler
|
||||||
ProfileH *profile.Handler
|
ProfileH *profile.Handler
|
||||||
@@ -42,10 +40,9 @@ func New(d Deps) *gin.Engine {
|
|||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
// Public : login, inscription + formulaire de contact, rate-limités.
|
// Public : login, inscription, rate-limités.
|
||||||
api.POST("/auth/login", httpx.RateLimit(1, 5), d.AuthH.Login)
|
api.POST("/auth/login", httpx.RateLimit(1, 5), d.AuthH.Login)
|
||||||
api.POST("/auth/register", httpx.RateLimit(0.2, 3), d.AuthH.Register)
|
api.POST("/auth/register", httpx.RateLimit(0.2, 3), d.AuthH.Register)
|
||||||
api.POST("/leads", httpx.RateLimit(1, 3), d.LeadsH.Create)
|
|
||||||
// Toute route authentifiée (session valide, n'importe quel rôle).
|
// Toute route authentifiée (session valide, n'importe quel rôle).
|
||||||
authed := api.Group("")
|
authed := api.Group("")
|
||||||
authed.Use(auth.RequireAuth(d.Issuer, d.Sessions))
|
authed.Use(auth.RequireAuth(d.Issuer, d.Sessions))
|
||||||
@@ -60,8 +57,6 @@ func New(d Deps) *gin.Engine {
|
|||||||
client := authed.Group("")
|
client := authed.Group("")
|
||||||
client.Use(auth.RequireRole(auth.RoleClient))
|
client.Use(auth.RequireRole(auth.RoleClient))
|
||||||
{
|
{
|
||||||
client.GET("/leads", d.LeadsH.List)
|
|
||||||
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
|
|
||||||
client.POST("/subscription", d.SubH.AddCode)
|
client.POST("/subscription", d.SubH.AddCode)
|
||||||
if d.DemosH != nil {
|
if d.DemosH != nil {
|
||||||
client.GET("/demos/mine", d.DemosH.ListMine)
|
client.GET("/demos/mine", d.DemosH.ListMine)
|
||||||
|
|||||||
Vendored
+423
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
|
||||||
|
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
|
||||||
|
<script type="module" crossorigin src="/assets/index-DRDLx_J5.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-24
@@ -2,10 +2,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { Center, Spinner } from '@chakra-ui/react'
|
import { Center, Spinner } from '@chakra-ui/react'
|
||||||
import { Landing } from './pages/Landing'
|
import { Landing } from './pages/Landing'
|
||||||
import { Pricing } from './pages/Pricing'
|
import { Pricing } from './pages/Pricing'
|
||||||
import { RequestDemo } from './pages/RequestDemo'
|
|
||||||
import { Login } from './pages/Login'
|
import { Login } from './pages/Login'
|
||||||
import { Register } from './pages/Register'
|
import { Register } from './pages/Register'
|
||||||
import { Leads } from './pages/backoffice/Leads'
|
|
||||||
import { Demos } from './pages/backoffice/Demos'
|
import { Demos } from './pages/backoffice/Demos'
|
||||||
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
||||||
import { Codes } from './pages/backoffice/Codes'
|
import { Codes } from './pages/backoffice/Codes'
|
||||||
@@ -31,14 +29,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
|||||||
// Réservé à l'admin : provisioning des démos.
|
// Réservé à l'admin : provisioning des démos.
|
||||||
function RequireAdmin({ children }: { children: JSX.Element }) {
|
function RequireAdmin({ children }: { children: JSX.Element }) {
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
return isAdmin ? children : <Navigate to="/app/leads" replace />
|
return isAdmin ? children : <Navigate to="/app/subscription" replace />
|
||||||
}
|
|
||||||
|
|
||||||
// Accès aux leads : autorisé pour l'admin, ou pour les comptes non premium.
|
|
||||||
function RequireLeadsAccess({ children }: { children: JSX.Element }) {
|
|
||||||
const { isAdmin, isPremium } = useAuth()
|
|
||||||
const canAccess = isAdmin || !isPremium
|
|
||||||
return canAccess ? children : <Navigate to="/app/subscription" replace />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirection d'accueil du back-office selon le rôle.
|
// Redirection d'accueil du back-office selon le rôle.
|
||||||
@@ -47,11 +38,8 @@ function BackofficeHome() {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return <Navigate to="/app/demos" replace />
|
return <Navigate to="/app/demos" replace />
|
||||||
case 'client':
|
|
||||||
return <Navigate to="/app/subscription" replace />
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Navigate to="/app/leads" replace />
|
return <Navigate to="/app/subscription" replace />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +50,6 @@ export function App() {
|
|||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/tarifs" element={<Pricing />} />
|
<Route path="/tarifs" element={<Pricing />} />
|
||||||
<Route path="/demo" element={<RequestDemo />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
@@ -78,15 +65,6 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<BackofficeHome />} />
|
<Route index element={<BackofficeHome />} />
|
||||||
|
|
||||||
<Route
|
|
||||||
path="leads"
|
|
||||||
element={
|
|
||||||
<RequireLeadsAccess>
|
|
||||||
<Leads />
|
|
||||||
</RequireLeadsAccess>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="demos"
|
path="demos"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const HamburgerIcon = () => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export function BackofficeLayout() {
|
export function BackofficeLayout() {
|
||||||
const { logout, isAdmin, isPremium, isClient } = useAuth()
|
const { logout, isAdmin, isClient } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
|
||||||
@@ -48,7 +48,6 @@ export function BackofficeLayout() {
|
|||||||
Omnex · {isAdmin ? 'Espace admin' : 'Espace client'}
|
Omnex · {isAdmin ? 'Espace admin' : 'Espace client'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
|
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
|
||||||
{(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>}
|
|
||||||
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
|
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
|
||||||
{(isAdmin || isClient) && <NavItem to="/app/profile">Profile</NavItem>}
|
{(isAdmin || isClient) && <NavItem to="/app/profile">Profile</NavItem>}
|
||||||
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
||||||
@@ -86,11 +85,6 @@ export function BackofficeLayout() {
|
|||||||
|
|
||||||
<DrawerBody py={6}>
|
<DrawerBody py={6}>
|
||||||
<Stack as="nav" spacing={1}>
|
<Stack as="nav" spacing={1}>
|
||||||
{(isAdmin || (isClient && !isPremium)) && (
|
|
||||||
<NavItem to="/app/leads" onClick={onClose} mobile>
|
|
||||||
Leads
|
|
||||||
</NavItem>
|
|
||||||
)}
|
|
||||||
{isClient && (
|
{isClient && (
|
||||||
<NavItem to="/app/subscription" onClick={onClose} mobile>
|
<NavItem to="/app/subscription" onClick={onClose} mobile>
|
||||||
Abonnement
|
Abonnement
|
||||||
|
|||||||
@@ -26,15 +26,11 @@ interface CreateDemoModalProps {
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
/** Pré-remplit le lien vers un lead existant (depuis la page Leads). */
|
|
||||||
leadId?: string
|
|
||||||
/** Username pré-rempli et verrouillé, ex. quand il vient du lead. */
|
|
||||||
lockedUsername?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUsername }: CreateDemoModalProps) {
|
export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [username, setUsername] = useState(lockedUsername ?? '')
|
const [username, setUsername] = useState('')
|
||||||
|
|
||||||
const [adminUsername, setAdminUsername] = useState('admin')
|
const [adminUsername, setAdminUsername] = useState('admin')
|
||||||
const [adminPassword, setAdminPassword] = useState('')
|
const [adminPassword, setAdminPassword] = useState('')
|
||||||
@@ -62,7 +58,7 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setUsername(lockedUsername ?? '')
|
setUsername('')
|
||||||
setAdminUsername('admin')
|
setAdminUsername('admin')
|
||||||
setAdminPassword('')
|
setAdminPassword('')
|
||||||
setEnableTelegram(false)
|
setEnableTelegram(false)
|
||||||
@@ -110,7 +106,6 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
|
|||||||
|
|
||||||
const params: CreateDemoParams = {
|
const params: CreateDemoParams = {
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
leadId,
|
|
||||||
adminUsername: adminUsername.trim(),
|
adminUsername: adminUsername.trim(),
|
||||||
adminPassword: adminPassword.trim(),
|
adminPassword: adminPassword.trim(),
|
||||||
telegramBotUsername: enableTelegram ? telegramBotUsername.trim() || undefined : undefined,
|
telegramBotUsername: enableTelegram ? telegramBotUsername.trim() || undefined : undefined,
|
||||||
@@ -157,7 +152,7 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
|
|||||||
<ModalCloseButton isDisabled={submitting} />
|
<ModalCloseButton isDisabled={submitting} />
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Stack spacing={5}>
|
<Stack spacing={5}>
|
||||||
<FormControl isRequired isDisabled={!!lockedUsername || submitting}>
|
<FormControl isRequired isDisabled={submitting}>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="ex: acme-corp"
|
placeholder="ex: acme-corp"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function Footer() {
|
|||||||
<FooterCol title="Produit">
|
<FooterCol title="Produit">
|
||||||
<FooterLink to="/">Présentation</FooterLink>
|
<FooterLink to="/">Présentation</FooterLink>
|
||||||
<FooterLink to="/tarifs">Tarifs</FooterLink>
|
<FooterLink to="/tarifs">Tarifs</FooterLink>
|
||||||
<FooterLink to="/demo">Demander une démo</FooterLink>
|
<FooterLink to="/register">Créer un compte</FooterLink>
|
||||||
</FooterCol>
|
</FooterCol>
|
||||||
|
|
||||||
<FooterCol title="Ressources">
|
<FooterCol title="Ressources">
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function Header() {
|
|||||||
<Button as={RouterLink} to="/login" variant="ghost" size="sm">
|
<Button as={RouterLink} to="/login" variant="ghost" size="sm">
|
||||||
Espace commercial
|
Espace commercial
|
||||||
</Button>
|
</Button>
|
||||||
<Button as={RouterLink} to="/demo" colorScheme="primary" size="sm">
|
<Button as={RouterLink} to="/register" colorScheme="primary" size="sm">
|
||||||
Demander une démo
|
Créer un compte
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@@ -113,12 +113,12 @@ export function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
to="/demo"
|
to="/register"
|
||||||
colorScheme="primary"
|
colorScheme="primary"
|
||||||
justifyContent="flex-start"
|
justifyContent="flex-start"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Demander une démo
|
Créer un compte
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Badge, Box, Flex, HStack, Icon, Progress, SimpleGrid, Stack, Text } from '@chakra-ui/react'
|
||||||
|
import type { ComponentState, DemoDetails } from '../lib/api'
|
||||||
|
import { podStatusColor, podStatusLabel } from '../lib/format'
|
||||||
|
|
||||||
|
export const PODSTATUS_COMPONENTS: { key: keyof DemoDetails['state']; label: string }[] = [
|
||||||
|
{ key: 'api', label: 'Backend' },
|
||||||
|
{ key: 'web', label: 'Frontend' },
|
||||||
|
{ key: 'db', label: 'PostgreSQL' },
|
||||||
|
{ key: 'dbm', label: 'Redis' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// PodStatusPanel : vue d'ensemble des 4 composants d'une démo (statut + CPU/mémoire live).
|
||||||
|
export function PodStatusPanel({ state }: { state: DemoDetails['state'] }) {
|
||||||
|
const allRunning = PODSTATUS_COMPONENTS.every((c) => state[c.key].phase === 'Running')
|
||||||
|
const downCount = PODSTATUS_COMPONENTS.filter((c) => state[c.key].phase !== 'Running').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Box
|
||||||
|
w="8px"
|
||||||
|
h="8px"
|
||||||
|
borderRadius="full"
|
||||||
|
bg={allRunning ? 'green.400' : 'red.400'}
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{allRunning
|
||||||
|
? 'Tous les services sont opérationnels'
|
||||||
|
: `${downCount} service${downCount > 1 ? 's' : ''} indisponible${downCount > 1 ? 's' : ''}`}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<SimpleGrid columns={{ base: 1, sm: 2, lg: 4 }} spacing={3}>
|
||||||
|
{PODSTATUS_COMPONENTS.map((c) => (
|
||||||
|
<ComponentCard key={c.key} title={c.label} cs={state[c.key]} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentCard({ title, cs }: { title: string; cs: ComponentState }) {
|
||||||
|
const cpuPct = cs.cpu_limit_milli > 0 ? Math.min(100, Math.round((cs.cpu_milli / cs.cpu_limit_milli) * 100)) : 0
|
||||||
|
const memPct = cs.memory_limit_mi > 0 ? Math.min(100, Math.round((cs.memory_mi / cs.memory_limit_mi) * 100)) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={3} borderWidth="1px" borderRadius="lg" bg="bg-surface">
|
||||||
|
<HStack justify="space-between" mb={3}>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={1.5}>
|
||||||
|
<Box w="7px" h="7px" borderRadius="full" bg={`${podStatusColor(cs.phase)}.400`} flexShrink={0} />
|
||||||
|
<Badge colorScheme={podStatusColor(cs.phase)} fontSize="10px">
|
||||||
|
{podStatusLabel(cs.phase)}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
|
||||||
|
<Text>CPU</Text>
|
||||||
|
<Text fontFamily="mono">
|
||||||
|
{cs.cpu_milli}m / {cs.cpu_limit_milli}m
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
value={cpuPct}
|
||||||
|
size="xs"
|
||||||
|
borderRadius="full"
|
||||||
|
colorScheme={cpuPct > 85 ? 'red' : cpuPct > 60 ? 'orange' : 'primary'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
|
||||||
|
<Text>Mémoire</Text>
|
||||||
|
<Text fontFamily="mono">
|
||||||
|
{cs.memory_mi}Mi / {cs.memory_limit_mi}Mi
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
value={memPct}
|
||||||
|
size="xs"
|
||||||
|
borderRadius="full"
|
||||||
|
colorScheme={memPct > 85 ? 'red' : memPct > 60 ? 'orange' : 'primary'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronIcon(props: React.ComponentProps<typeof Icon>) {
|
||||||
|
return (
|
||||||
|
<Icon viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} {...props}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 18l6-6-6-6" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,14 +44,6 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
|
|
||||||
export type Role = 'admin' | 'client'
|
export type Role = 'admin' | 'client'
|
||||||
|
|
||||||
export interface Lead {
|
|
||||||
id: string
|
|
||||||
telegram: string
|
|
||||||
message?: string
|
|
||||||
status: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DemoStatus =
|
export type DemoStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
| 'provisioning'
|
| 'provisioning'
|
||||||
@@ -63,7 +55,6 @@ export type DemoStatus =
|
|||||||
export interface Demo {
|
export interface Demo {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
lead_id?: string
|
|
||||||
status: DemoStatus
|
status: DemoStatus
|
||||||
namespace: string
|
namespace: string
|
||||||
url: string
|
url: string
|
||||||
@@ -76,7 +67,6 @@ export type StorageDriver = 'local' | 's3'
|
|||||||
|
|
||||||
export interface CreateDemoParams {
|
export interface CreateDemoParams {
|
||||||
username: string
|
username: string
|
||||||
leadId?: string
|
|
||||||
telegramBotUsername?: string
|
telegramBotUsername?: string
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
nowPaymentsApiKey?: string
|
nowPaymentsApiKey?: string
|
||||||
@@ -165,19 +155,12 @@ export const api = {
|
|||||||
expired_at: string
|
expired_at: string
|
||||||
}>('GET', '/auth/me'), logout: () => request<{ status: string }>('POST', '/auth/logout'),
|
}>('GET', '/auth/me'), logout: () => request<{ status: string }>('POST', '/auth/logout'),
|
||||||
|
|
||||||
createLead: (telegram: string, message?: string) =>
|
|
||||||
request<Lead>('POST', '/leads', { telegram, message }),
|
|
||||||
listLeads: () => request<{ items: Lead[] }>('GET', '/leads'),
|
|
||||||
setLeadStatus: (id: string, status: string) =>
|
|
||||||
request<Lead>('PATCH', `/leads/${id}/status`, { status }),
|
|
||||||
|
|
||||||
listDemos: () => request<{ items: Demo[] }>('GET', '/demos'),
|
listDemos: () => request<{ items: Demo[] }>('GET', '/demos'),
|
||||||
listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'),
|
listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'),
|
||||||
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
||||||
createDemo: (params: CreateDemoParams) =>
|
createDemo: (params: CreateDemoParams) =>
|
||||||
request<Demo>('POST', '/demos', {
|
request<Demo>('POST', '/demos', {
|
||||||
username: params.username,
|
username: params.username,
|
||||||
...(params.leadId ? { lead_id: params.leadId } : {}),
|
|
||||||
...(params.telegramBotUsername ? { telegram_bot_username: params.telegramBotUsername } : {}),
|
...(params.telegramBotUsername ? { telegram_bot_username: params.telegramBotUsername } : {}),
|
||||||
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
|
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
|
||||||
...(params.nowPaymentsApiKey ? { nowpayments_api_key: params.nowPaymentsApiKey } : {}),
|
...(params.nowPaymentsApiKey ? { nowpayments_api_key: params.nowPaymentsApiKey } : {}),
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ export function Landing() {
|
|||||||
<Stack direction={{ base: 'column', sm: 'row' }} spacing={4} w={{ base: 'full', sm: 'auto' }}>
|
<Stack direction={{ base: 'column', sm: 'row' }} spacing={4} w={{ base: 'full', sm: 'auto' }}>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
to="/demo"
|
to="/register"
|
||||||
colorScheme="primary"
|
colorScheme="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
w={{ base: 'full', sm: 'auto' }}
|
w={{ base: 'full', sm: 'auto' }}
|
||||||
>
|
>
|
||||||
Demander une démo
|
Créer un compte
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const plans: Plan[] = [
|
|||||||
'Disponible 30 jours',
|
'Disponible 30 jours',
|
||||||
'Accompagnement commercial',
|
'Accompagnement commercial',
|
||||||
],
|
],
|
||||||
cta: 'Demander une démo',
|
cta: 'Créer un compte',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pro',
|
name: 'Pro',
|
||||||
@@ -87,7 +87,7 @@ export function Pricing() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Text textAlign="center" color="gray.500" mt={10} fontSize="sm">
|
<Text textAlign="center" color="gray.500" mt={10} fontSize="sm">
|
||||||
Besoin d'un devis précis ? <RouterLinkText to="/demo">Demandez une démo</RouterLinkText> — un
|
Besoin d'un devis précis ? <RouterLinkText to="/register">Créez un compte</RouterLinkText> — un
|
||||||
commercial vous recontacte.
|
commercial vous recontacte.
|
||||||
</Text>
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -146,7 +146,7 @@ function PlanCard({ plan }: { plan: Plan }) {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
to="/demo"
|
to="/register"
|
||||||
colorScheme="primary"
|
colorScheme="primary"
|
||||||
variant={plan.highlighted ? 'solid' : 'outline'}
|
variant={plan.highlighted ? 'solid' : 'outline'}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { SaasProvider } from '@saas-ui/react'
|
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
|
||||||
import { RequestDemo } from './RequestDemo'
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<SaasProvider>
|
|
||||||
<MemoryRouter>
|
|
||||||
<RequestDemo />
|
|
||||||
</MemoryRouter>
|
|
||||||
</SaasProvider>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RequestDemo', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('affiche le formulaire de demande de démo', () => {
|
|
||||||
renderPage()
|
|
||||||
expect(screen.getByRole('heading', { name: /demander une démo/i })).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/entreprise/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('envoie le lead et affiche la confirmation', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 201,
|
|
||||||
json: async () => ({ id: '1', company: 'ACME', email: 'a@acme.io', status: 'new' }),
|
|
||||||
})
|
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
|
||||||
|
|
||||||
renderPage()
|
|
||||||
const user = userEvent.setup()
|
|
||||||
await user.type(screen.getByLabelText(/entreprise/i), 'ACME')
|
|
||||||
await user.type(screen.getByLabelText(/email professionnel/i), 'a@acme.io')
|
|
||||||
await user.click(screen.getByRole('button', { name: /envoyer ma demande/i }))
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByRole('heading', { name: /merci/i })).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Container,
|
|
||||||
FormControl,
|
|
||||||
FormLabel,
|
|
||||||
Heading,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
|
||||||
import { api, ApiError } from '../lib/api'
|
|
||||||
|
|
||||||
export function RequestDemo() {
|
|
||||||
const toast = useToast()
|
|
||||||
const [telegram, setTelegram] = useState('')
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [sent, setSent] = useState(false)
|
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
await api.createLead(telegram.trim(), message.trim())
|
|
||||||
setSent(true)
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof ApiError ? err.message : 'Envoi impossible'
|
|
||||||
toast({ status: 'error', title: 'Échec', description: msg })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxW="md" py={16}>
|
|
||||||
<Stack spacing={6}>
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Heading size="lg">Demander une démo</Heading>
|
|
||||||
<Text color="gray.500">
|
|
||||||
Laissez vos coordonnées, un commercial déploiera votre démo dédiée (30 jours).
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{sent ? (
|
|
||||||
<Card>
|
|
||||||
<CardBody>
|
|
||||||
<Stack spacing={4} textAlign="center">
|
|
||||||
<Heading size="md">Merci !</Heading>
|
|
||||||
<Text>Votre demande a bien été enregistrée. Nous revenons vers vous rapidement.</Text>
|
|
||||||
<Button as={RouterLink} to="/" variant="outline">
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardBody>
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<Stack spacing={4}>
|
|
||||||
<FormControl isRequired>
|
|
||||||
<FormLabel>Telegram</FormLabel>
|
|
||||||
<Input value={telegram} onChange={(e) => setTelegram(e.target.value)} />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>Message (optionnel)</FormLabel>
|
|
||||||
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} rows={4} />
|
|
||||||
</FormControl>
|
|
||||||
<Button type="submit" colorScheme="primary" isLoading={loading}>
|
|
||||||
Envoyer ma demande
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,11 +9,8 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
Link,
|
Link,
|
||||||
Progress,
|
|
||||||
SimpleGrid,
|
|
||||||
Spacer,
|
Spacer,
|
||||||
Spinner,
|
Spinner,
|
||||||
Stack,
|
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
Tbody,
|
Tbody,
|
||||||
@@ -25,23 +22,17 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api'
|
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
|
||||||
import { podStatusColor, podStatusLabel, statusColor, statusLabel, timeRemaining } from '../../lib/format'
|
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
|
||||||
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||||
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
||||||
|
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
||||||
|
|
||||||
// Un provisioning en cours => on rafraîchit régulièrement.
|
// Un provisioning en cours => on rafraîchit régulièrement.
|
||||||
const POLL_MS = 5000
|
const POLL_MS = 5000
|
||||||
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
|
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
|
||||||
const DETAILS_POLL_MS = 5000
|
const DETAILS_POLL_MS = 5000
|
||||||
|
|
||||||
const COMPONENTS: { key: keyof DemoDetails['state']; label: string }[] = [
|
|
||||||
{ key: 'api', label: 'Backend' },
|
|
||||||
{ key: 'web', label: 'Frontend' },
|
|
||||||
{ key: 'db', label: 'PostgreSQL' },
|
|
||||||
{ key: 'dbm', label: 'Redis' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Demos() {
|
export function Demos() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -59,7 +50,10 @@ export function Demos() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.listDemos()
|
const res = await api.listDemos()
|
||||||
setDemos(res.items ?? [])
|
// Les démos passées en abonnement payant vivent désormais dans le
|
||||||
|
// dashboard Premium (n'expirent plus, stockage persistant) — ne plus
|
||||||
|
// les mélanger avec les démos d'essai ici.
|
||||||
|
setDemos((res.items ?? []).filter((d) => d.type_abonnement !== 'premium'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) navigate('/login')
|
if (err instanceof ApiError && err.status === 401) navigate('/login')
|
||||||
else toast({ status: 'error', title: 'Chargement des démos impossible' })
|
else toast({ status: 'error', title: 'Chargement des démos impossible' })
|
||||||
@@ -167,7 +161,7 @@ export function Demos() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : demos.length === 0 ? (
|
) : demos.length === 0 ? (
|
||||||
<Text color="gray.500">Aucune démo active. Lancez-en une depuis un lead ou ci-dessus.</Text>
|
<Text color="gray.500">Aucune démo active. Lancez-en une avec le bouton ci-dessus.</Text>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer borderWidth="1px" borderRadius="lg">
|
<TableContainer borderWidth="1px" borderRadius="lg">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -302,93 +296,3 @@ export function Demos() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PodStatusPanel : vue d'ensemble des 4 composants d'une démo (statut + CPU/mémoire live).
|
|
||||||
function PodStatusPanel({ state }: { state: DemoDetails['state'] }) {
|
|
||||||
const allRunning = COMPONENTS.every((c) => state[c.key].phase === 'Running')
|
|
||||||
const downCount = COMPONENTS.filter((c) => state[c.key].phase !== 'Running').length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Box
|
|
||||||
w="8px"
|
|
||||||
h="8px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg={allRunning ? 'green.400' : 'red.400'}
|
|
||||||
flexShrink={0}
|
|
||||||
/>
|
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
|
||||||
{allRunning
|
|
||||||
? 'Tous les services sont opérationnels'
|
|
||||||
: `${downCount} service${downCount > 1 ? 's' : ''} indisponible${downCount > 1 ? 's' : ''}`}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<SimpleGrid columns={{ base: 1, sm: 2, lg: 4 }} spacing={3}>
|
|
||||||
{COMPONENTS.map((c) => (
|
|
||||||
<ComponentCard key={c.key} title={c.label} cs={state[c.key]} />
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentCard({ title, cs }: { title: string; cs: ComponentState }) {
|
|
||||||
const cpuPct = cs.cpu_limit_milli > 0 ? Math.min(100, Math.round((cs.cpu_milli / cs.cpu_limit_milli) * 100)) : 0
|
|
||||||
const memPct = cs.memory_limit_mi > 0 ? Math.min(100, Math.round((cs.memory_mi / cs.memory_limit_mi) * 100)) : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box p={3} borderWidth="1px" borderRadius="lg" bg="bg-surface">
|
|
||||||
<HStack justify="space-between" mb={3}>
|
|
||||||
<Text fontSize="sm" fontWeight="semibold">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<HStack spacing={1.5}>
|
|
||||||
<Box w="7px" h="7px" borderRadius="full" bg={`${podStatusColor(cs.phase)}.400`} flexShrink={0} />
|
|
||||||
<Badge colorScheme={podStatusColor(cs.phase)} fontSize="10px">
|
|
||||||
{podStatusLabel(cs.phase)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box>
|
|
||||||
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
|
|
||||||
<Text>CPU</Text>
|
|
||||||
<Text fontFamily="mono">
|
|
||||||
{cs.cpu_milli}m / {cs.cpu_limit_milli}m
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<Progress
|
|
||||||
value={cpuPct}
|
|
||||||
size="xs"
|
|
||||||
borderRadius="full"
|
|
||||||
colorScheme={cpuPct > 85 ? 'red' : cpuPct > 60 ? 'orange' : 'primary'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
|
|
||||||
<Text>Mémoire</Text>
|
|
||||||
<Text fontFamily="mono">
|
|
||||||
{cs.memory_mi}Mi / {cs.memory_limit_mi}Mi
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
<Progress
|
|
||||||
value={memPct}
|
|
||||||
size="xs"
|
|
||||||
borderRadius="full"
|
|
||||||
colorScheme={memPct > 85 ? 'red' : memPct > 60 ? 'orange' : 'primary'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChevronIcon(props: React.ComponentProps<typeof Icon>) {
|
|
||||||
return (
|
|
||||||
<Icon viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 18l6-6-6-6" />
|
|
||||||
</Icon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
Heading,
|
|
||||||
Spinner,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
Tbody,
|
|
||||||
Td,
|
|
||||||
Text,
|
|
||||||
Th,
|
|
||||||
Thead,
|
|
||||||
Tr,
|
|
||||||
useToast,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { api, ApiError, type Lead } from '../../lib/api'
|
|
||||||
import { formatDate } from '../../lib/format'
|
|
||||||
import { useAuth } from '../../lib/auth'
|
|
||||||
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
|
||||||
|
|
||||||
export function Leads() {
|
|
||||||
const toast = useToast()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { isAdmin } = useAuth()
|
|
||||||
const [leads, setLeads] = useState<Lead[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [launchingFor, setLaunchingFor] = useState<Lead | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.listLeads()
|
|
||||||
setLeads(res.items ?? [])
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError && err.status === 401) navigate('/login')
|
|
||||||
else toast({ status: 'error', title: 'Chargement des leads impossible' })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [navigate, toast])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
if (loading) return <Spinner />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Heading size="md" mb={6}>
|
|
||||||
Leads
|
|
||||||
</Heading>
|
|
||||||
{leads.length === 0 ? (
|
|
||||||
<Text color="gray.500">Aucun lead pour le moment.</Text>
|
|
||||||
) : (
|
|
||||||
<TableContainer borderWidth="1px" borderRadius="lg">
|
|
||||||
<Table>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th>Telegram</Th>
|
|
||||||
<Th>Statut</Th>
|
|
||||||
<Th>Message</Th>
|
|
||||||
<Th>Reçu le</Th>
|
|
||||||
<Th></Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{leads.map((l) => (
|
|
||||||
<Tr key={l.id}>
|
|
||||||
<Td fontWeight="medium">{l.telegram}</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge>{l.status}</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td fontWeight="medium">{l.message}</Td>
|
|
||||||
<Td>{formatDate(l.created_at)}</Td>
|
|
||||||
<Td textAlign="right">
|
|
||||||
{isAdmin && (
|
|
||||||
<Flex justify="flex-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
colorScheme="primary"
|
|
||||||
onClick={() => setLaunchingFor(l)}
|
|
||||||
>
|
|
||||||
Lancer une démo
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateDemoModal
|
|
||||||
isOpen={!!launchingFor}
|
|
||||||
onClose={() => setLaunchingFor(null)}
|
|
||||||
onCreated={() => {
|
|
||||||
void load()
|
|
||||||
navigate('/app/demos')
|
|
||||||
}}
|
|
||||||
leadId={launchingFor?.id}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
|
Collapse,
|
||||||
Heading,
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
Link,
|
Link,
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Table,
|
||||||
@@ -15,11 +19,14 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api, ApiError, type Demo } from '../../lib/api'
|
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
|
||||||
import { statusColor, statusLabel } from '../../lib/format'
|
import { statusColor, statusLabel } from '../../lib/format'
|
||||||
|
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
||||||
|
|
||||||
// Un provisioning en cours => on rafraîchit régulièrement.
|
// Un provisioning en cours => on rafraîchit régulièrement.
|
||||||
const POLL_MS = 5000
|
const POLL_MS = 5000
|
||||||
|
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
|
||||||
|
const DETAILS_POLL_MS = 5000
|
||||||
|
|
||||||
export function PremiumDemos() {
|
export function PremiumDemos() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -27,6 +34,11 @@ export function PremiumDemos() {
|
|||||||
const [demos, setDemos] = useState<Demo[]>([])
|
const [demos, setDemos] = useState<Demo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// --- Ligne dépliée (état live des pods) ---
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [details, setDetails] = useState<DemoDetails | null>(null)
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.listDemos()
|
const res = await api.listDemos()
|
||||||
@@ -45,6 +57,45 @@ export function PremiumDemos() {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const toggleRow = async (d: Demo) => {
|
||||||
|
if (expandedId === d.id) {
|
||||||
|
setExpandedId(null)
|
||||||
|
setDetails(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedId(d.id)
|
||||||
|
setDetails(null)
|
||||||
|
setDetailsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.getDemoDetails(d.namespace)
|
||||||
|
setDetails(res)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
|
toast({ status: 'error', title: 'État des pods indisponible', description: msg })
|
||||||
|
setExpandedId(null)
|
||||||
|
} finally {
|
||||||
|
setDetailsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tant qu'une ligne est dépliée, on rafraîchit l'état des pods en direct
|
||||||
|
// (silencieux : pas de spinner, juste la mise à jour des badges/jauges).
|
||||||
|
useEffect(() => {
|
||||||
|
const current = demos.find((d) => d.id === expandedId)
|
||||||
|
if (!current) return
|
||||||
|
const namespace = current.namespace
|
||||||
|
const id = setInterval(() => {
|
||||||
|
api
|
||||||
|
.getDemoDetails(namespace)
|
||||||
|
.then(setDetails)
|
||||||
|
.catch(() => {
|
||||||
|
/* échec silencieux : on garde le dernier état connu affiché */
|
||||||
|
})
|
||||||
|
}, DETAILS_POLL_MS)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [expandedId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading size="md" mb={2}>
|
<Heading size="md" mb={2}>
|
||||||
@@ -70,24 +121,67 @@ export function PremiumDemos() {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{demos.map((d) => (
|
{demos.map((d) => {
|
||||||
<Tr key={d.id}>
|
const isOpen = expandedId === d.id
|
||||||
<Td fontFamily="mono">{d.namespace}</Td>
|
return (
|
||||||
<Td>{d.username || <Text color="gray.400">—</Text>}</Td>
|
<Fragment key={d.id}>
|
||||||
<Td>
|
<Tr
|
||||||
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
cursor="pointer"
|
||||||
</Td>
|
bg={isOpen ? 'chakra-subtle-bg' : undefined}
|
||||||
<Td>
|
_hover={{ bg: 'chakra-subtle-bg' }}
|
||||||
{d.status === 'ready' ? (
|
onClick={() => toggleRow(d)}
|
||||||
<Link href={d.url} color="primary.500" isExternal>
|
>
|
||||||
{d.url}
|
<Td fontFamily="mono">
|
||||||
</Link>
|
<HStack spacing={2}>
|
||||||
) : (
|
<Icon
|
||||||
<Text color="gray.400">—</Text>
|
as={ChevronIcon}
|
||||||
)}
|
boxSize={3}
|
||||||
</Td>
|
color="gray.400"
|
||||||
</Tr>
|
transform={isOpen ? 'rotate(90deg)' : undefined}
|
||||||
))}
|
transition="transform 0.15s"
|
||||||
|
/>
|
||||||
|
<Text>{d.namespace}</Text>
|
||||||
|
</HStack>
|
||||||
|
</Td>
|
||||||
|
<Td>{d.username || <Text color="gray.400">—</Text>}</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{d.status === 'ready' ? (
|
||||||
|
<Link
|
||||||
|
href={d.url}
|
||||||
|
color="primary.500"
|
||||||
|
isExternal
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{d.url}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Text color="gray.400">—</Text>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
<Tr>
|
||||||
|
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={4}>
|
||||||
|
<Collapse in={isOpen} unmountOnExit animateOpacity>
|
||||||
|
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
|
||||||
|
{detailsLoading && !details ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : details ? (
|
||||||
|
<PodStatusPanel state={details.state} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray.500" fontSize="sm">
|
||||||
|
Aucune donnée.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/Contact.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/RequestDemo.test.tsx","./src/pages/RequestDemo.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Contact.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/Leads.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/CreateDemoModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user