chore: update
ci-api / test (push) Successful in 22m11s
ci-web / test (push) Successful in 13m32s

This commit is contained in:
Xor290
2026-08-02 16:54:09 +02:00
parent 09928c0d76
commit 6992e505ae
26 changed files with 681 additions and 695 deletions
+2 -24
View File
@@ -2,10 +2,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { Center, Spinner } from '@chakra-ui/react'
import { Landing } from './pages/Landing'
import { Pricing } from './pages/Pricing'
import { RequestDemo } from './pages/RequestDemo'
import { Login } from './pages/Login'
import { Register } from './pages/Register'
import { Leads } from './pages/backoffice/Leads'
import { Demos } from './pages/backoffice/Demos'
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
import { Codes } from './pages/backoffice/Codes'
@@ -31,14 +29,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
// Réservé à l'admin : provisioning des démos.
function RequireAdmin({ children }: { children: JSX.Element }) {
const { isAdmin } = useAuth()
return isAdmin ? children : <Navigate to="/app/leads" 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 />
return isAdmin ? children : <Navigate to="/app/subscription" replace />
}
// Redirection d'accueil du back-office selon le rôle.
@@ -47,11 +38,8 @@ function BackofficeHome() {
switch (role) {
case 'admin':
return <Navigate to="/app/demos" replace />
case 'client':
return <Navigate to="/app/subscription" replace />
default:
return <Navigate to="/app/leads" replace />
return <Navigate to="/app/subscription" replace />
}
}
@@ -62,7 +50,6 @@ export function App() {
<Route element={<PublicLayout />}>
<Route path="/" element={<Landing />} />
<Route path="/tarifs" element={<Pricing />} />
<Route path="/demo" element={<RequestDemo />} />
</Route>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
@@ -78,15 +65,6 @@ export function App() {
>
<Route index element={<BackofficeHome />} />
<Route
path="leads"
element={
<RequireLeadsAccess>
<Leads />
</RequireLeadsAccess>
}
/>
<Route
path="demos"
element={
+1 -7
View File
@@ -29,7 +29,7 @@ const HamburgerIcon = () => (
)
export function BackofficeLayout() {
const { logout, isAdmin, isPremium, isClient } = useAuth()
const { logout, isAdmin, isClient } = useAuth()
const navigate = useNavigate()
const { isOpen, onOpen, onClose } = useDisclosure()
@@ -48,7 +48,6 @@ export function BackofficeLayout() {
Omnex · {isAdmin ? 'Espace admin' : 'Espace client'}
</Heading>
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
{(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>}
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
{(isAdmin || isClient) && <NavItem to="/app/profile">Profile</NavItem>}
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
@@ -86,11 +85,6 @@ export function BackofficeLayout() {
<DrawerBody py={6}>
<Stack as="nav" spacing={1}>
{(isAdmin || (isClient && !isPremium)) && (
<NavItem to="/app/leads" onClick={onClose} mobile>
Leads
</NavItem>
)}
{isClient && (
<NavItem to="/app/subscription" onClick={onClose} mobile>
Abonnement
+4 -9
View File
@@ -26,15 +26,11 @@ interface CreateDemoModalProps {
isOpen: boolean
onClose: () => 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 [username, setUsername] = useState(lockedUsername ?? '')
const [username, setUsername] = useState('')
const [adminUsername, setAdminUsername] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
@@ -62,7 +58,7 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const [submitting, setSubmitting] = useState(false)
const reset = () => {
setUsername(lockedUsername ?? '')
setUsername('')
setAdminUsername('admin')
setAdminPassword('')
setEnableTelegram(false)
@@ -110,7 +106,6 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const params: CreateDemoParams = {
username: username.trim(),
leadId,
adminUsername: adminUsername.trim(),
adminPassword: adminPassword.trim(),
telegramBotUsername: enableTelegram ? telegramBotUsername.trim() || undefined : undefined,
@@ -157,7 +152,7 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
<ModalCloseButton isDisabled={submitting} />
<ModalBody>
<Stack spacing={5}>
<FormControl isRequired isDisabled={!!lockedUsername || submitting}>
<FormControl isRequired isDisabled={submitting}>
<FormLabel>Username</FormLabel>
<Input
placeholder="ex: acme-corp"
+1 -1
View File
@@ -18,7 +18,7 @@ export function Footer() {
<FooterCol title="Produit">
<FooterLink to="/">Présentation</FooterLink>
<FooterLink to="/tarifs">Tarifs</FooterLink>
<FooterLink to="/demo">Demander une démo</FooterLink>
<FooterLink to="/register">Créer un compte</FooterLink>
</FooterCol>
<FooterCol title="Ressources">
+4 -4
View File
@@ -64,8 +64,8 @@ export function Header() {
<Button as={RouterLink} to="/login" variant="ghost" size="sm">
Espace commercial
</Button>
<Button as={RouterLink} to="/demo" colorScheme="primary" size="sm">
Demander une démo
<Button as={RouterLink} to="/register" colorScheme="primary" size="sm">
Créer un compte
</Button>
</HStack>
@@ -113,12 +113,12 @@ export function Header() {
</Button>
<Button
as={RouterLink}
to="/demo"
to="/register"
colorScheme="primary"
justifyContent="flex-start"
onClick={onClose}
>
Demander une démo
Créer un compte
</Button>
</Stack>
</DrawerBody>
+100
View File
@@ -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>
)
}
-17
View File
@@ -44,14 +44,6 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
export type Role = 'admin' | 'client'
export interface Lead {
id: string
telegram: string
message?: string
status: string
created_at: string
}
export type DemoStatus =
| 'pending'
| 'provisioning'
@@ -63,7 +55,6 @@ export type DemoStatus =
export interface Demo {
id: string
username: string
lead_id?: string
status: DemoStatus
namespace: string
url: string
@@ -76,7 +67,6 @@ export type StorageDriver = 'local' | 's3'
export interface CreateDemoParams {
username: string
leadId?: string
telegramBotUsername?: string
telegramBotToken?: string
nowPaymentsApiKey?: string
@@ -165,19 +155,12 @@ export const api = {
expired_at: string
}>('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'),
listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'),
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
createDemo: (params: CreateDemoParams) =>
request<Demo>('POST', '/demos', {
username: params.username,
...(params.leadId ? { lead_id: params.leadId } : {}),
...(params.telegramBotUsername ? { telegram_bot_username: params.telegramBotUsername } : {}),
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
...(params.nowPaymentsApiKey ? { nowpayments_api_key: params.nowPaymentsApiKey } : {}),
+2 -2
View File
@@ -23,12 +23,12 @@ export function Landing() {
<Stack direction={{ base: 'column', sm: 'row' }} spacing={4} w={{ base: 'full', sm: 'auto' }}>
<Button
as={RouterLink}
to="/demo"
to="/register"
colorScheme="primary"
size="lg"
w={{ base: 'full', sm: 'auto' }}
>
Demander une démo
Créer un compte
</Button>
<Button
as={RouterLink}
+3 -3
View File
@@ -37,7 +37,7 @@ const plans: Plan[] = [
'Disponible 30 jours',
'Accompagnement commercial',
],
cta: 'Demander une démo',
cta: 'Créer un compte',
},
{
name: 'Pro',
@@ -87,7 +87,7 @@ export function Pricing() {
</SimpleGrid>
<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.
</Text>
</Container>
@@ -146,7 +146,7 @@ function PlanCard({ plan }: { plan: Plan }) {
<Button
as={RouterLink}
to="/demo"
to="/register"
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
-51
View File
@@ -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()
})
})
-87
View File
@@ -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&nbsp;!</Heading>
<Text>Votre demande a bien é 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>
)
}
+8 -104
View File
@@ -9,11 +9,8 @@ import {
HStack,
Icon,
Link,
Progress,
SimpleGrid,
Spacer,
Spinner,
Stack,
Table,
TableContainer,
Tbody,
@@ -25,23 +22,17 @@ import {
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api'
import { podStatusColor, podStatusLabel, statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateDemoModal } from '../../components/CreateDemoModal'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un provisioning en cours => on rafraîchit régulièrement.
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
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() {
const toast = useToast()
const navigate = useNavigate()
@@ -59,7 +50,10 @@ export function Demos() {
const load = useCallback(async () => {
try {
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) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des démos impossible' })
@@ -167,7 +161,7 @@ export function Demos() {
{loading ? (
<Spinner />
) : 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">
<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>
)
}
-109
View File
@@ -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}
/>
</>
)
}
+114 -20
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Collapse,
Heading,
HStack,
Icon,
Link,
Spinner,
Table,
@@ -15,11 +19,14 @@ import {
useToast,
} from '@chakra-ui/react'
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 { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un provisioning en cours => on rafraîchit régulièrement.
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() {
const toast = useToast()
@@ -27,6 +34,11 @@ export function PremiumDemos() {
const [demos, setDemos] = useState<Demo[]>([])
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 () => {
try {
const res = await api.listDemos()
@@ -45,6 +57,45 @@ export function PremiumDemos() {
return () => clearInterval(id)
}, [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 (
<>
<Heading size="md" mb={2}>
@@ -70,24 +121,67 @@ export function PremiumDemos() {
</Tr>
</Thead>
<Tbody>
{demos.map((d) => (
<Tr key={d.id}>
<Td fontFamily="mono">{d.namespace}</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>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
</Td>
</Tr>
))}
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
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>
</Table>
</TableContainer>