From cb966fcaf010014ba589122f1b977d478fccb732 Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Sat, 1 Aug 2026 23:27:22 +0200 Subject: [PATCH] feat: add new page and update --- web/src/App.tsx | 10 ++ web/src/components/BackofficeLayout.tsx | 6 + web/src/components/CreateDemoModal.tsx | 164 ++++++++++++++++++++++ web/src/lib/api.ts | 22 ++- web/src/pages/backoffice/Demos.tsx | 61 ++------ web/src/pages/backoffice/Leads.tsx | 30 ++-- web/src/pages/backoffice/PremiumDemos.tsx | 97 +++++++++++++ 7 files changed, 319 insertions(+), 71 deletions(-) create mode 100644 web/src/components/CreateDemoModal.tsx create mode 100644 web/src/pages/backoffice/PremiumDemos.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 898d477..8c3a6c2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ 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' import { Subscription } from './pages/backoffice/Subscription' import { Profile } from './pages/backoffice/Profile' @@ -103,6 +104,15 @@ export function App() { } /> + + + + + } + /> } /> } /> diff --git a/web/src/components/BackofficeLayout.tsx b/web/src/components/BackofficeLayout.tsx index 17feac9..ef4bdd5 100644 --- a/web/src/components/BackofficeLayout.tsx +++ b/web/src/components/BackofficeLayout.tsx @@ -52,6 +52,7 @@ export function BackofficeLayout() { {isClient && Abonnement} {(isAdmin || isClient) && Profile} {isAdmin && Démos} + {isAdmin && Premium} {isAdmin && Codes} @@ -100,6 +101,11 @@ export function BackofficeLayout() { Démos )} + {isAdmin && ( + + Premium + + )} {isAdmin && ( Codes diff --git a/web/src/components/CreateDemoModal.tsx b/web/src/components/CreateDemoModal.tsx new file mode 100644 index 0000000..1007f2d --- /dev/null +++ b/web/src/components/CreateDemoModal.tsx @@ -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('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 ( + + + + Nouvelle démo + + + + + Username + setUsername(e.target.value)} + /> + + + + Token bot Telegram + setTelegramBotToken(e.target.value)} + type="password" + autoComplete="off" + /> + + Optionnel — laissez vide si le bot n'est pas encore prêt. + + + + + Stockage des fichiers + setStorageDriver(v as StorageDriver)}> + + Local (disque du cluster) + S3 + + + + + {storageDriver === 's3' && ( + + + Nom du bucket + setS3Bucket(e.target.value)} + /> + + + Endpoint S3 + setS3Endpoint(e.target.value)} + /> + + + )} + + + + + + + + + ) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4707fa5..1373303 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -72,6 +72,17 @@ export interface Demo { 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 { id: string username: string @@ -151,10 +162,15 @@ export const api = { listDemos: () => request<{ items: Demo[] }>('GET', '/demos'), listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'), getDemo: (id: string) => request('GET', `/demos/${id}`), - createDemo: (username: string, leadId?: string) => + createDemo: (params: CreateDemoParams) => request('POST', '/demos', { - username, - ...(leadId ? { lead_id: leadId } : {}), + username: params.username, + ...(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('POST', `/demos/${id}/extend`), deleteDemo: (id: string) => request('DELETE', `/demos/${id}`), diff --git a/web/src/pages/backoffice/Demos.tsx b/web/src/pages/backoffice/Demos.tsx index 8b1ce5d..f92152a 100644 --- a/web/src/pages/backoffice/Demos.tsx +++ b/web/src/pages/backoffice/Demos.tsx @@ -5,12 +5,9 @@ import { Button, Collapse, Flex, - FormControl, - FormLabel, Heading, HStack, Icon, - Input, Link, Progress, SimpleGrid, @@ -31,6 +28,7 @@ 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 { ConfirmDialog } from '../../components/ConfirmDialog' +import { CreateDemoModal } from '../../components/CreateDemoModal' // Un provisioning en cours => on rafraîchit régulièrement. const POLL_MS = 5000 @@ -51,10 +49,7 @@ export function Demos() { const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(null) const [toDelete, setToDelete] = useState(null) - - // --- Formulaire de création --- - const [newUsername, setNewUsername] = useState('') - const [newLeadId, setNewLeadId] = useState('') + const [createModalOpen, setCreateModalOpen] = useState(false) // --- Ligne dépliée (état live des pods) --- const [expandedId, setExpandedId] = useState(null) @@ -79,26 +74,6 @@ export function Demos() { return () => clearInterval(id) }, [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) => { setBusy(d.id) try { @@ -173,38 +148,22 @@ export function Demos() { return ( <> - + Démos - - - Username - - setNewUsername(e.target.value)} - /> - - - - Lead ID (optionnel) - - setNewLeadId(e.target.value)} - /> - - + setCreateModalOpen(false)} + onCreated={() => void load()} + /> + {loading ? ( ) : demos.length === 0 ? ( diff --git a/web/src/pages/backoffice/Leads.tsx b/web/src/pages/backoffice/Leads.tsx index 5de6f21..31bb93f 100644 --- a/web/src/pages/backoffice/Leads.tsx +++ b/web/src/pages/backoffice/Leads.tsx @@ -19,6 +19,7 @@ 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() @@ -26,7 +27,7 @@ export function Leads() { const { isAdmin } = useAuth() const [leads, setLeads] = useState([]) const [loading, setLoading] = useState(true) - const [launching, setLaunching] = useState(null) + const [launchingFor, setLaunchingFor] = useState(null) const load = useCallback(async () => { try { @@ -44,20 +45,6 @@ export function Leads() { void 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 return ( @@ -94,8 +81,7 @@ export function Leads() { @@ -108,6 +94,16 @@ export function Leads() { )} + + setLaunchingFor(null)} + onCreated={() => { + void load() + navigate('/app/demos') + }} + leadId={launchingFor?.id} + /> ) } diff --git a/web/src/pages/backoffice/PremiumDemos.tsx b/web/src/pages/backoffice/PremiumDemos.tsx new file mode 100644 index 0000000..67231b8 --- /dev/null +++ b/web/src/pages/backoffice/PremiumDemos.tsx @@ -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([]) + 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 ( + <> + + Démos Premium + + + Démos rattachées à un client passé en abonnement payant — stockage persistant, n'expirent plus. + + + {loading ? ( + + ) : demos.length === 0 ? ( + Aucune démo premium pour le moment. + ) : ( + + + + + + + + + + + + {demos.map((d) => ( + + + + + + + ))} + +
NamespaceClientStatutURL
{d.namespace}{d.username || } + {statusLabel(d.status)} + + {d.status === 'ready' ? ( + + {d.url} + + ) : ( + + )} +
+
+ )} + + ) +}