feat: add new page and update
ci-web / test (push) Canceled after 4m40s

This commit is contained in:
Nuxgrid
2026-08-01 23:27:22 +02:00
parent 5d2a132c3c
commit cb966fcaf0
7 changed files with 319 additions and 71 deletions
+10
View File
@@ -7,6 +7,7 @@ import { Login } from './pages/Login'
import { Register } from './pages/Register' import { Register } from './pages/Register'
import { Leads } from './pages/backoffice/Leads' 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 { Codes } from './pages/backoffice/Codes' import { Codes } from './pages/backoffice/Codes'
import { Subscription } from './pages/backoffice/Subscription' import { Subscription } from './pages/backoffice/Subscription'
import { Profile } from './pages/backoffice/Profile' import { Profile } from './pages/backoffice/Profile'
@@ -103,6 +104,15 @@ export function App() {
</RequireAdmin> </RequireAdmin>
} }
/> />
<Route
path="premium"
element={
<RequireAdmin>
<PremiumDemos />
</RequireAdmin>
}
/>
<Route path="subscription" element={<Subscription />} /> <Route path="subscription" element={<Subscription />} />
<Route path="profile" element={<Profile />} /> <Route path="profile" element={<Profile />} />
</Route> </Route>
+6
View File
@@ -52,6 +52,7 @@ export function BackofficeLayout() {
{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>}
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>} {isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
</HStack> </HStack>
<Spacer /> <Spacer />
@@ -100,6 +101,11 @@ export function BackofficeLayout() {
Démos Démos
</NavItem> </NavItem>
)} )}
{isAdmin && (
<NavItem to="/app/premium" onClick={onClose} mobile>
Premium
</NavItem>
)}
{isAdmin && ( {isAdmin && (
<NavItem to="/app/codes" onClick={onClose} mobile> <NavItem to="/app/codes" onClick={onClose} mobile>
Codes Codes
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import {
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { api, ApiError, type CreateDemoParams, type StorageDriver } from '../lib/api'
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) {
const toast = useToast()
const [username, setUsername] = useState(lockedUsername ?? '')
const [telegramBotToken, setTelegramBotToken] = useState('')
const [storageDriver, setStorageDriver] = useState<StorageDriver>('local')
const [s3Bucket, setS3Bucket] = useState('')
const [s3Endpoint, setS3Endpoint] = useState('')
const [submitting, setSubmitting] = useState(false)
const reset = () => {
setUsername(lockedUsername ?? '')
setTelegramBotToken('')
setStorageDriver('local')
setS3Bucket('')
setS3Endpoint('')
}
const handleClose = () => {
if (submitting) return
reset()
onClose()
}
const handleSubmit = async () => {
if (!username.trim()) {
toast({ status: 'warning', title: 'Username requis' })
return
}
if (storageDriver === 's3' && (!s3Bucket.trim() || !s3Endpoint.trim())) {
toast({ status: 'warning', title: 'Bucket et endpoint S3 requis' })
return
}
const params: CreateDemoParams = {
username: username.trim(),
leadId,
telegramBotToken: telegramBotToken.trim() || undefined,
storageDriver,
...(storageDriver === 's3'
? { s3Bucket: s3Bucket.trim(), s3Endpoint: s3Endpoint.trim() }
: {}),
}
setSubmitting(true)
try {
await api.createDemo(params)
toast({ status: 'success', title: 'Démo lancée', description: 'Provisioning en cours.' })
reset()
onCreated()
onClose()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setSubmitting(false)
}
}
return (
<Modal isOpen={isOpen} onClose={handleClose} size="lg" closeOnOverlayClick={!submitting}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Nouvelle démo</ModalHeader>
<ModalCloseButton isDisabled={submitting} />
<ModalBody>
<Stack spacing={5}>
<FormControl isRequired isDisabled={!!lockedUsername || submitting}>
<FormLabel>Username</FormLabel>
<Input
placeholder="ex: acme-corp"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel>Token bot Telegram</FormLabel>
<Input
placeholder="123456:ABC-DEF..."
value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)}
type="password"
autoComplete="off"
/>
<Text fontSize="xs" color="gray.500" mt={1}>
Optionnel laissez vide si le bot n'est pas encore prêt.
</Text>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel>Stockage des fichiers</FormLabel>
<RadioGroup value={storageDriver} onChange={(v) => setStorageDriver(v as StorageDriver)}>
<Stack direction="row" spacing={6}>
<Radio value="local">Local (disque du cluster)</Radio>
<Radio value="s3">S3</Radio>
</Stack>
</RadioGroup>
</FormControl>
{storageDriver === 's3' && (
<Stack spacing={4} pl={1} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isRequired isDisabled={submitting} pl={3}>
<FormLabel>Nom du bucket</FormLabel>
<Input
placeholder="mon-bucket-demo"
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
/>
</FormControl>
<FormControl isRequired isDisabled={submitting} pl={3}>
<FormLabel>Endpoint S3</FormLabel>
<Input
placeholder="https://s3.exemple.com"
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
/>
</FormControl>
</Stack>
)}
</Stack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={submitting}>
Annuler
</Button>
<Button colorScheme="primary" onClick={handleSubmit} isLoading={submitting}>
Lancer la démo
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
+19 -3
View File
@@ -72,6 +72,17 @@ export interface Demo {
expires_at: string expires_at: string
} }
export type StorageDriver = 'local' | 's3'
export interface CreateDemoParams {
username: string
leadId?: string
telegramBotToken?: string
storageDriver: StorageDriver
s3Bucket?: string
s3Endpoint?: string
}
export interface CodeBuySub { export interface CodeBuySub {
id: string id: string
username: string username: string
@@ -151,10 +162,15 @@ export const api = {
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: (username: string, leadId?: string) => createDemo: (params: CreateDemoParams) =>
request<Demo>('POST', '/demos', { request<Demo>('POST', '/demos', {
username, username: params.username,
...(leadId ? { lead_id: leadId } : {}), ...(params.leadId ? { lead_id: params.leadId } : {}),
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
storage_driver: params.storageDriver,
...(params.storageDriver === 's3'
? { s3_bucket: params.s3Bucket, s3_endpoint: params.s3Endpoint }
: {}),
}), }),
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`), extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`), deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),
+10 -51
View File
@@ -5,12 +5,9 @@ import {
Button, Button,
Collapse, Collapse,
Flex, Flex,
FormControl,
FormLabel,
Heading, Heading,
HStack, HStack,
Icon, Icon,
Input,
Link, Link,
Progress, Progress,
SimpleGrid, SimpleGrid,
@@ -31,6 +28,7 @@ import { useNavigate } from 'react-router-dom'
import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api' import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api'
import { podStatusColor, podStatusLabel, statusColor, statusLabel, timeRemaining } from '../../lib/format' import { podStatusColor, podStatusLabel, statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog' import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateDemoModal } from '../../components/CreateDemoModal'
// 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
@@ -51,10 +49,7 @@ export function Demos() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
const [toDelete, setToDelete] = useState<Demo | null>(null) const [toDelete, setToDelete] = useState<Demo | null>(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
// --- Formulaire de création ---
const [newUsername, setNewUsername] = useState('')
const [newLeadId, setNewLeadId] = useState('')
// --- Ligne dépliée (état live des pods) --- // --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -79,26 +74,6 @@ export function Demos() {
return () => clearInterval(id) return () => clearInterval(id)
}, [load]) }, [load])
const create = async () => {
if (!newUsername.trim()) {
toast({ status: 'warning', title: 'Username requis' })
return
}
setBusy('new')
try {
await api.createDemo(newUsername.trim(), newLeadId.trim() || undefined)
toast({ status: 'success', title: 'Démo lancée' })
setNewUsername('')
setNewLeadId('')
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setBusy(null)
}
}
const extend = async (d: Demo) => { const extend = async (d: Demo) => {
setBusy(d.id) setBusy(d.id)
try { try {
@@ -173,38 +148,22 @@ export function Demos() {
return ( return (
<> <>
<Flex mb={6} align="flex-end" gap={4} wrap="wrap"> <Flex mb={6} align="center" gap={4} wrap="wrap">
<Heading size="md" mr={4}> <Heading size="md" mr={4}>
Démos Démos
</Heading> </Heading>
<Spacer /> <Spacer />
<FormControl maxW="220px"> <Button colorScheme="primary" onClick={() => setCreateModalOpen(true)}>
<FormLabel fontSize="sm" mb={1}>
Username
</FormLabel>
<Input
size="sm"
placeholder="ex: acme-corp"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
</FormControl>
<FormControl maxW="220px">
<FormLabel fontSize="sm" mb={1}>
Lead ID (optionnel)
</FormLabel>
<Input
size="sm"
placeholder="uuid du lead"
value={newLeadId}
onChange={(e) => setNewLeadId(e.target.value)}
/>
</FormControl>
<Button colorScheme="primary" isLoading={busy === 'new'} onClick={create}>
Nouvelle démo Nouvelle démo
</Button> </Button>
</Flex> </Flex>
<CreateDemoModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onCreated={() => void load()}
/>
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : demos.length === 0 ? ( ) : demos.length === 0 ? (
+13 -17
View File
@@ -19,6 +19,7 @@ import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Lead } from '../../lib/api' import { api, ApiError, type Lead } from '../../lib/api'
import { formatDate } from '../../lib/format' import { formatDate } from '../../lib/format'
import { useAuth } from '../../lib/auth' import { useAuth } from '../../lib/auth'
import { CreateDemoModal } from '../../components/CreateDemoModal'
export function Leads() { export function Leads() {
const toast = useToast() const toast = useToast()
@@ -26,7 +27,7 @@ export function Leads() {
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [leads, setLeads] = useState<Lead[]>([]) const [leads, setLeads] = useState<Lead[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [launching, setLaunching] = useState<string | null>(null) const [launchingFor, setLaunchingFor] = useState<Lead | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -44,20 +45,6 @@ export function Leads() {
void load() void load()
}, [load]) }, [load])
const launchDemo = async (lead: Lead) => {
setLaunching(lead.id)
try {
await api.createDemo(lead.id)
toast({ status: 'success', title: 'Démo lancée', description: 'Provisioning en cours.' })
navigate('/app/demos')
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setLaunching(null)
}
}
if (loading) return <Spinner /> if (loading) return <Spinner />
return ( return (
@@ -94,8 +81,7 @@ export function Leads() {
<Button <Button
size="sm" size="sm"
colorScheme="primary" colorScheme="primary"
isLoading={launching === l.id} onClick={() => setLaunchingFor(l)}
onClick={() => launchDemo(l)}
> >
Lancer une démo Lancer une démo
</Button> </Button>
@@ -108,6 +94,16 @@ export function Leads() {
</Table> </Table>
</TableContainer> </TableContainer>
)} )}
<CreateDemoModal
isOpen={!!launchingFor}
onClose={() => setLaunchingFor(null)}
onCreated={() => {
void load()
navigate('/app/demos')
}}
leadId={launchingFor?.id}
/>
</> </>
) )
} }
+97
View File
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Heading,
Link,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo } from '../../lib/api'
import { statusColor, statusLabel } from '../../lib/format'
// Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
export function PremiumDemos() {
const toast = useToast()
const navigate = useNavigate()
const [demos, setDemos] = useState<Demo[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
try {
const res = await api.listDemos()
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' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
return (
<>
<Heading size="md" mb={2}>
Démos Premium
</Heading>
<Text color="gray.500" mb={6} fontSize="sm">
Démos rattachées à un client passé en abonnement payant stockage persistant, n'expirent plus.
</Text>
{loading ? (
<Spinner />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo premium pour le moment.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
</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>
))}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}