fix: multiple error
This commit is contained in:
@@ -118,12 +118,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Traefik partagé + WAF Coraza : installé une fois pour toutes les démos
|
// Traefik partagé + WAF Coraza : installé une fois pour toutes les démos
|
||||||
// (idempotent). Non-fatal : si ça échoue, l'API démarre quand même, mais
|
// (idempotent). En arrière-plan et non-fatal : "helm upgrade --install
|
||||||
// les démos provisionnées ensuite ne seront ni routées ni protégées tant
|
// --wait --timeout 5m" peut bloquer plusieurs minutes si le cluster est
|
||||||
// que ce n'est pas corrigé.
|
// lent/injoignable, ce qui retardait le démarrage du serveur HTTP au
|
||||||
if err := helmProv.EnsureSharedInfra(); err != nil {
|
// point de faire échouer le healthcheck Docker avant même que l'API
|
||||||
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err)
|
// n'écoute sur le port. Ici ça ne retarde plus rien : les démos
|
||||||
}
|
// provisionnées avant la fin de cet appel ne seront juste ni routées ni
|
||||||
|
// protégées tant qu'il n'a pas fini.
|
||||||
|
go func() {
|
||||||
|
if err := helmProv.EnsureSharedInfra(); err != nil {
|
||||||
|
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Service de démos avec le provisioner Helm
|
// Service de démos avec le provisioner Helm
|
||||||
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
|
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ type createRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DetailDemoUserRequest struct {
|
type DetailDemoUserRequest struct {
|
||||||
Username string `json:"username" binding:"required,min=3,max=64,alphanum"`
|
// Namespace identifie la démo de façon unique. Le username ne suffit
|
||||||
|
// pas : un même client peut avoir plusieurs démos (historique, relances),
|
||||||
|
// et interroger par username renvoyait toujours la même (la plus
|
||||||
|
// ancienne), jamais celle réellement sélectionnée dans l'UI.
|
||||||
|
Namespace string `json:"namespace" binding:"required,min=3,max=63"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DemoDetailsResponse struct {
|
type DemoDetailsResponse struct {
|
||||||
Username string `json:"username"`
|
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
State ResourceState `json:"state"`
|
State ResourceState `json:"state"`
|
||||||
}
|
}
|
||||||
@@ -42,11 +45,14 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
d, err := h.svc.Create(req.LeadID, req.Username)
|
d, err := h.svc.Create(req.LeadID, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrCapacityReached) {
|
switch {
|
||||||
|
case errors.Is(err, ErrCapacityReached):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
|
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
|
||||||
return
|
case errors.Is(err, ErrUserHasActiveDemo):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusAccepted, d)
|
c.JSON(http.StatusAccepted, d)
|
||||||
@@ -62,6 +68,21 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListMine : GET /demos/mine — démo(s) rattachée(s) au client authentifié.
|
||||||
|
func (h *Handler) ListMine(c *gin.Context) {
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.ListForUser(p.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ListDetails(c *gin.Context) {
|
func (h *Handler) ListDetails(c *gin.Context) {
|
||||||
p := auth.PrincipalFrom(c)
|
p := auth.PrincipalFrom(c)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
@@ -74,26 +95,19 @@ func (h *Handler) ListDetails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req DetailDemoUserRequest
|
var req DetailDemoUserRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" {
|
if err := c.ShouldBindJSON(&req); err != nil || req.Namespace == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username requis"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace requis"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
demo, err := h.svc.store.GetDemoByUsername(req.Username)
|
state, err := h.helm.GetResourceState(c.Request.Context(), req.Namespace)
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := h.helm.GetResourceState(c.Request.Context(), demo.Namespace)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, DemoDetailsResponse{
|
c.JSON(http.StatusOK, DemoDetailsResponse{
|
||||||
Username: req.Username,
|
Namespace: req.Namespace,
|
||||||
Namespace: demo.Namespace,
|
|
||||||
State: state,
|
State: state,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
|
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
|
||||||
ErrNotFound = errors.New("démo introuvable")
|
ErrNotFound = errors.New("démo introuvable")
|
||||||
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
|
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
|
||||||
|
ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config du service (injectable pour les tests).
|
// Config du service (injectable pour les tests).
|
||||||
@@ -50,8 +51,22 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
|
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
|
||||||
// username (optionnel) rattache la démo à un client existant.
|
// username (optionnel) rattache la démo à un client existant. Un client n'a
|
||||||
|
// droit qu'à une seule démo active à la fois.
|
||||||
func (s *Service) Create(leadID, username string) (Demo, error) {
|
func (s *Service) Create(leadID, username string) (Demo, error) {
|
||||||
|
normalizedUsername := auth.NormalizeUsername(username)
|
||||||
|
if normalizedUsername != "" {
|
||||||
|
existing, err := s.store.ListByUsername(normalizedUsername)
|
||||||
|
if err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
for _, d := range existing {
|
||||||
|
if d.Status.Active() {
|
||||||
|
return Demo{}, ErrUserHasActiveDemo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
active, err := s.store.CountActive()
|
active, err := s.store.CountActive()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Demo{}, err
|
return Demo{}, err
|
||||||
@@ -63,7 +78,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
|
|||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
demo := Demo{
|
demo := Demo{
|
||||||
ID: id,
|
ID: id,
|
||||||
Username: auth.NormalizeUsername(username),
|
Username: normalizedUsername,
|
||||||
LeadID: leadID,
|
LeadID: leadID,
|
||||||
Status: StatusPending,
|
Status: StatusPending,
|
||||||
Namespace: "demo-" + shortID(id),
|
Namespace: "demo-" + shortID(id),
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ func (h *Handler) UpdateUsernameById(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, ok := c.MustGet("id").(string)
|
p := auth.PrincipalFrom(c)
|
||||||
if !ok {
|
if p == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.UpdateUsername(id, u.Username)
|
user, err := h.store.UpdateUsername(p.UserID, u.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
||||||
return
|
return
|
||||||
@@ -57,9 +57,9 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, ok := c.MustGet("id").(string)
|
p := auth.PrincipalFrom(c)
|
||||||
if !ok {
|
if p == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.UpdatePassword(id, hash)
|
user, err := h.store.UpdatePassword(p.UserID, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
return
|
return
|
||||||
@@ -79,12 +79,12 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetTelegramById(c *gin.Context) {
|
func (h *Handler) GetTelegramById(c *gin.Context) {
|
||||||
id, ok := c.MustGet("id").(string)
|
p := auth.PrincipalFrom(c)
|
||||||
if !ok {
|
if p == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.store.GetTelegram(id)
|
user, err := h.store.GetTelegram(p.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
return
|
return
|
||||||
@@ -98,12 +98,12 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, ok := c.MustGet("id").(string)
|
p := auth.PrincipalFrom(c)
|
||||||
if !ok {
|
if p == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.store.SetTelegram(id, t.Telegram)
|
user, err := h.store.SetTelegram(p.UserID, t.Telegram)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ func New(d Deps) *gin.Engine {
|
|||||||
client.GET("/leads", d.LeadsH.List)
|
client.GET("/leads", d.LeadsH.List)
|
||||||
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
|
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
|
||||||
client.POST("/subscription", d.SubH.AddCode)
|
client.POST("/subscription", d.SubH.AddCode)
|
||||||
|
if d.DemosH != nil {
|
||||||
|
client.GET("/demos/mine", d.DemosH.ListMine)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Espace admin : provisioning des démos (admin uniquement).
|
// Espace admin : provisioning des démos (admin uniquement).
|
||||||
admin := authed.Group("")
|
admin := authed.Group("")
|
||||||
|
|||||||
@@ -88,11 +88,17 @@ traefik:
|
|||||||
enabled: true
|
enabled: true
|
||||||
allowCrossNamespace: true
|
allowCrossNamespace: true
|
||||||
|
|
||||||
# IP réelle via X-Forwarded-For (profondeur 1 = proxy direct)
|
# Cluster (pas Local) : sur un cluster bare-metal multi-nœuds sans
|
||||||
# Le rate-limiter et les logs utilisent cette IP
|
# LoadBalancer, "Local" fait DROP le trafic NodePort sur tout nœud qui ne
|
||||||
|
# fait pas tourner le pod Traefik (comportement kube-proxy documenté —
|
||||||
|
# "has no local endpoints"), donc l'URL casse selon le nœud contacté et
|
||||||
|
# où le pod est schedulé. "Cluster" relaie toujours vers le bon nœud, au
|
||||||
|
# prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le
|
||||||
|
# rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant
|
||||||
|
# qu'il n'y a pas de LoadBalancer (MetalLB) devant.
|
||||||
service:
|
service:
|
||||||
spec:
|
spec:
|
||||||
externalTrafficPolicy: Local
|
externalTrafficPolicy: Cluster
|
||||||
|
|
||||||
# Métriques Prometheus
|
# Métriques Prometheus
|
||||||
metrics:
|
metrics:
|
||||||
|
|||||||
+3
-3
@@ -104,7 +104,6 @@ export interface DemoState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DemoDetails {
|
export interface DemoDetails {
|
||||||
username: string
|
|
||||||
namespace: string
|
namespace: string
|
||||||
state: DemoState
|
state: DemoState
|
||||||
}
|
}
|
||||||
@@ -150,6 +149,7 @@ export const api = {
|
|||||||
request<Lead>('PATCH', `/leads/${id}/status`, { status }),
|
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'),
|
||||||
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
||||||
createDemo: (username: string, leadId?: string) =>
|
createDemo: (username: string, leadId?: string) =>
|
||||||
request<Demo>('POST', '/demos', {
|
request<Demo>('POST', '/demos', {
|
||||||
@@ -167,8 +167,8 @@ export const api = {
|
|||||||
sendMessage: (username: string, telegram: string, sujet: string, message: string) =>
|
sendMessage: (username: string, telegram: string, sujet: string, message: string) =>
|
||||||
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
||||||
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
||||||
getDemoDetails: (username: string) =>
|
getDemoDetails: (namespace: string) =>
|
||||||
request<DemoDetails>('POST', '/demos/details', { username }),
|
request<DemoDetails>('POST', '/demos/details', { namespace }),
|
||||||
updateUsername: (username: string) =>
|
updateUsername: (username: string) =>
|
||||||
request<UpdateUsername>('POST', '/profile/username', { username }),
|
request<UpdateUsername>('POST', '/profile/username', { username }),
|
||||||
updatePassword: (password: string) =>
|
updatePassword: (password: string) =>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function Demos() {
|
|||||||
setDetails(null)
|
setDetails(null)
|
||||||
setDetailsLoading(true)
|
setDetailsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.getDemoDetails(d.username)
|
const res = await api.getDemoDetails(d.namespace)
|
||||||
setDetails(res)
|
setDetails(res)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
@@ -156,10 +156,10 @@ export function Demos() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = demos.find((d) => d.id === expandedId)
|
const current = demos.find((d) => d.id === expandedId)
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const username = current.username
|
const namespace = current.namespace
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
api
|
api
|
||||||
.getDemoDetails(username)
|
.getDemoDetails(namespace)
|
||||||
.then(setDetails)
|
.then(setDetails)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* échec silencieux : on garde le dernier état connu affiché */
|
/* échec silencieux : on garde le dernier état connu affiché */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
Input,
|
Input,
|
||||||
|
Link,
|
||||||
Spacer,
|
Spacer,
|
||||||
Spinner,
|
Spinner,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -17,7 +18,8 @@ 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 } from '../../lib/api'
|
import { api, ApiError, type Demo } from '../../lib/api'
|
||||||
|
import { statusColor, statusLabel } from '../../lib/format'
|
||||||
|
|
||||||
export function Subscription() {
|
export function Subscription() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -28,6 +30,8 @@ export function Subscription() {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [showRenewForm, setShowRenewForm] = useState(false)
|
const [showRenewForm, setShowRenewForm] = useState(false)
|
||||||
|
const [demos, setDemos] = useState<Demo[]>([])
|
||||||
|
const [demosLoading, setDemosLoading] = useState(true)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -42,9 +46,21 @@ export function Subscription() {
|
|||||||
}
|
}
|
||||||
}, [navigate, toast])
|
}, [navigate, toast])
|
||||||
|
|
||||||
|
const loadDemos = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.listMyDemos()
|
||||||
|
setDemos(res.items ?? [])
|
||||||
|
} catch {
|
||||||
|
// Silencieux : l'absence de démo n'est pas une erreur à afficher ici.
|
||||||
|
} finally {
|
||||||
|
setDemosLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [load])
|
void loadDemos()
|
||||||
|
}, [load, loadDemos])
|
||||||
|
|
||||||
const submitCode = async (e: React.FormEvent) => {
|
const submitCode = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -76,6 +92,36 @@ export function Subscription() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Flex mb={6} align="center">
|
||||||
|
<Heading size="md">Ma démo</Heading>
|
||||||
|
<Spacer />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
|
||||||
|
{demosLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : demos.length === 0 ? (
|
||||||
|
<Text color="gray.500">Aucune démo pour le moment.</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{demos.map((d) => (
|
||||||
|
<HStack key={d.id} justify="space-between" flexWrap="wrap" rowGap={2}>
|
||||||
|
{d.status === 'ready' ? (
|
||||||
|
<Link href={d.url} color="primary.500" isExternal fontFamily="mono">
|
||||||
|
{d.url}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Text color="gray.400" fontFamily="mono">
|
||||||
|
{d.url || '—'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Flex mb={6} align="center">
|
<Flex mb={6} align="center">
|
||||||
<Heading size="md">Mon abonnement</Heading>
|
<Heading size="md">Mon abonnement</Heading>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
|||||||
Reference in New Issue
Block a user