This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}`),
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user