feat: add web
ci-web / test (push) Failing after 26m34s

This commit is contained in:
Nuxgrid
2026-07-27 19:13:56 +02:00
parent bb6c425840
commit 07db8a2ae2
39 changed files with 7534 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
import { useState, type FormEvent } from 'react'
import {
Box,
Button,
Container,
Divider,
Flex,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
Input,
Link,
Stack,
Text,
Textarea,
SimpleGrid,
useToast,
} from '@chakra-ui/react'
import { api, ApiError } from '../lib/api'
interface ContactForm {
name: string
telegram: string
subject: string
message: string
}
const initialForm: ContactForm = {
name: '',
telegram: '',
subject: '',
message: '',
}
// Remplace par le vrai handle / lien de contact Telegram d'Omnex.
const TELEGRAM_CONTACT_URL = 'https://t.me/OMNEX_CORP'
const TelegramIcon = () => (
<Box as="svg" w="20px" h="20px" viewBox="0 0 24 24" fill="currentColor">
<Box
as="path"
d="M21.05 3.79a1.5 1.5 0 0 0-1.53-.25L2.6 10.1a1.5 1.5 0 0 0 .07 2.8l4.44 1.48 1.7 5.46a1.5 1.5 0 0 0 2.5.6l2.4-2.32 4.36 3.2a1.5 1.5 0 0 0 2.38-.86l3.1-14.9a1.5 1.5 0 0 0-.5-1.77ZM8.9 13.6l8.9-6.2-7.2 7.4-.3 3.2-1.4-4.4Z"
/>
</Box>
)
export function Contact() {
const toast = useToast()
const [form, setForm] = useState<ContactForm>(initialForm)
const [errors, setErrors] = useState<Partial<Record<keyof ContactForm, string>>>({})
const [submitting, setSubmitting] = useState(false)
const handleChange =
(field: keyof ContactForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
const validate = (): boolean => {
const nextErrors: Partial<Record<keyof ContactForm, string>> = {}
if (!form.name.trim()) nextErrors.name = 'Le nom est requis.'
if (!form.telegram.trim()) {
nextErrors.telegram = "L'email est requis."
}
if (!form.subject.trim()) nextErrors.subject = 'Le sujet est requis.'
if (!form.message.trim()) nextErrors.message = 'Le message est requis.'
setErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!validate()) return
setSubmitting(true)
try {
await api.sendMessage(form.name, form.telegram, form.subject, form.message)
toast({
title: 'Message envoyé',
description: 'Nous vous répondrons dans les plus brefs délais.',
status: 'success',
duration: 5000,
isClosable: true,
})
setForm(initialForm)
setErrors({})
} catch (err) {
const description =
err instanceof ApiError ? err.message : 'Merci de réessayer dans quelques instants.'
toast({
title: "Erreur lors de l'envoi",
description,
status: 'error',
duration: 5000,
isClosable: true,
})
} finally {
setSubmitting(false)
}
}
return (
<Box bg="chakra-subtle-bg" py={{ base: 12, md: 20 }}>
<Container maxW="container.md">
<Stack spacing={3} mb={10} textAlign="center">
<Heading size="xl">Contactez-nous</Heading>
<Text color="gray.400" fontSize="lg">
Une question, une démo à planifier, un projet ? Écrivez-nous.
</Text>
</Stack>
<Flex justify="center" mb={10}>
<Button
as={Link}
href={TELEGRAM_CONTACT_URL}
isExternal
leftIcon={<TelegramIcon />}
colorScheme="telegram"
size="lg"
w={{ base: 'full', sm: 'auto' }}
whiteSpace="normal"
_hover={{ textDecoration: 'none' }}
>
Nous contacter sur Telegram
</Button>
</Flex>
<Flex align="center" mb={10} gap={4}>
<Divider />
<Text fontSize="sm" color="gray.500" whiteSpace="nowrap">
ou par formulaire
</Text>
<Divider />
</Flex>
<Box
as="form"
onSubmit={handleSubmit}
bg="bg-surface"
borderWidth="1px"
borderColor="chakra-border-color"
borderRadius="xl"
p={{ base: 6, md: 10 }}
boxShadow="lg"
>
<Stack spacing={6}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>Nom</FormLabel>
<Input
value={form.name}
onChange={handleChange('name')}
placeholder="Votre nom"
size="lg"
/>
<FormErrorMessage>{errors.name}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.telegram}>
<FormLabel>Telegram</FormLabel>
<Input
type="email"
value={form.telegram}
onChange={handleChange('telegram')}
placeholder="vous@entreprise.com"
size="lg"
/>
<FormErrorMessage>{errors.telegram}</FormErrorMessage>
</FormControl>
</SimpleGrid>
<FormControl isInvalid={!!errors.subject}>
<FormLabel>Sujet</FormLabel>
<Input
value={form.subject}
onChange={handleChange('subject')}
placeholder="Demande de démo, question tarifaire..."
size="lg"
/>
<FormErrorMessage>{errors.subject}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.message}>
<FormLabel>Message</FormLabel>
<Textarea
value={form.message}
onChange={handleChange('message')}
placeholder="Décrivez votre besoin..."
rows={6}
resize="vertical"
/>
<FormErrorMessage>{errors.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="primary"
size="lg"
alignSelf={{ base: 'stretch', md: 'flex-start' }}
isLoading={submitting}
loadingText="Envoi..."
>
Envoyer le message
</Button>
</Stack>
</Box>
</Container>
</Box>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Box, Button, Container, Heading, SimpleGrid, Stack, Text } from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
const features = [
{ title: 'Gestion de commandes', desc: 'Client, admin, cabine, livreur — un flux complet de bout en bout.' },
{ title: 'Livraison temps réel', desc: 'GPS TomTom, auto-assignation des livreurs, ETA et navigation.' },
{ title: 'Paiements & notifications', desc: 'NowPayments (crypto), Telegram.' },
{ title: 'Sécurisé par design', desc: 'WAF ModSecurity/Coraza, TLS, JWT, isolation par démo.' },
]
export function Landing() {
return (
<Box>
{/* Hero */}
<Box bgGradient="linear(to-b, blackAlpha.50, transparent)" py={{ base: 16, md: 24 }}>
<Container maxW="container.lg">
<Stack spacing={6} textAlign="center" align="center">
<Heading size="2xl">La plateforme de gestion de commandes & livraison</Heading>
<Text fontSize="xl" color="gray.600" maxW="2xl">
Testez la solution complète en conditions réelles. Une démo isolée, déployée en un
clic, disponible pendant 30 jours.
</Text>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={4} w={{ base: 'full', sm: 'auto' }}>
<Button
as={RouterLink}
to="/demo"
colorScheme="primary"
size="lg"
w={{ base: 'full', sm: 'auto' }}
>
Demander une démo
</Button>
<Button
as={RouterLink}
to="/tarifs"
variant="outline"
size="lg"
w={{ base: 'full', sm: 'auto' }}
>
Voir les tarifs
</Button>
</Stack>
</Stack>
</Container>
</Box>
{/* Fonctionnalités */}
<Container maxW="container.lg" py={16}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{features.map((f) => (
<Box key={f.title} p={6} borderWidth="1px" borderRadius="lg">
<Heading size="md" mb={2}>{f.title}</Heading>
<Text color="gray.600">{f.desc}</Text>
</Box>
))}
</SimpleGrid>
</Container>
</Box>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
export function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await login(username.trim(), password)
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
toast({ status: 'error', title: 'Échec de connexion', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={20} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Espace commercial</Heading>
<Text color="gray.500">Connectez-vous pour gérer les démos.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Mot de passe</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormControl>
<Button type="submit" colorScheme="primary" isLoading={loading}>
Se connecter
</Button>
</Stack>
</form>
</CardBody>
</Card>
<HStack justify="center" spacing={1}>
<Text fontSize="sm" color="gray.500">
Pas encore de compte ?
</Text>
<Button as={RouterLink} to="/register" variant="link" size="sm">
Créer un compte
</Button>
</HStack>
<Button as={RouterLink} to="/" variant="link" size="sm">
Retour au site
</Button>
</Stack>
</Container>
)
}
+178
View File
@@ -0,0 +1,178 @@
import {
Badge,
Box,
Button,
Container,
Heading,
HStack,
Icon,
List,
ListItem,
SimpleGrid,
Stack,
Text,
} from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
interface Plan {
name: string
price: string
period: string
description: string
features: string[]
cta: string
highlighted?: boolean
}
const plans: Plan[] = [
{
name: 'Démo',
price: 'Gratuit',
period: '30 jours',
description: 'Une instance isolée et complète pour évaluer la solution.',
features: [
'Plateforme complète en conditions réelles',
'Environnement dédié et isolé',
'Données de démonstration pré-remplies',
'Disponible 30 jours',
'Accompagnement commercial',
],
cta: 'Demander une démo',
},
{
name: 'Pro',
price: 'Sur devis',
period: 'par mois',
description: 'Pour déployer la plateforme en production sur votre activité.',
features: [
'Tout ce qui est inclus dans Démo',
'Déploiement production dédié',
'WAF, TLS, sauvegardes',
'GPS, paiements et notifications',
'Support prioritaire',
],
cta: 'Nous contacter',
highlighted: true,
},
{
name: 'Entreprise',
price: 'Sur mesure',
period: '',
description: 'Multi-sites, SLA et intégrations spécifiques.',
features: [
'Tout ce qui est inclus dans Pro',
'Haute disponibilité multi-régions',
'SLA et supervision 24/7',
'Intégrations sur mesure',
'Accompagnement dédié',
],
cta: 'Nous contacter',
},
]
export function Pricing() {
return (
<Container maxW="container.lg" py={{ base: 12, md: 20 }}>
<Stack spacing={4} textAlign="center" mb={12} align="center">
<Heading size="2xl">Tarifs</Heading>
<Text fontSize="lg" color="gray.600" maxW="2xl">
Commencez par une démo gratuite de 30 jours, puis passez en production quand vous êtes prêt.
</Text>
</Stack>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8} alignItems="stretch">
{plans.map((plan) => (
<PlanCard key={plan.name} plan={plan} />
))}
</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
commercial vous recontacte.
</Text>
</Container>
)
}
function PlanCard({ plan }: { plan: Plan }) {
return (
<Stack
spacing={6}
p={8}
borderWidth={plan.highlighted ? '2px' : '1px'}
borderColor={plan.highlighted ? 'primary.500' : 'inherit'}
borderRadius="xl"
position="relative"
boxShadow={plan.highlighted ? 'lg' : 'sm'}
bg="bg-surface"
>
{plan.highlighted && (
<Badge
colorScheme="primary"
position="absolute"
top={-3}
left="50%"
transform="translateX(-50%)"
px={3}
py={1}
borderRadius="full"
>
Le plus choisi
</Badge>
)}
<Box>
<Heading size="md">{plan.name}</Heading>
<Text color="gray.500" mt={1} fontSize="sm">
{plan.description}
</Text>
</Box>
<HStack align="baseline" spacing={2}>
<Text fontSize="3xl" fontWeight="bold">
{plan.price}
</Text>
{plan.period && <Text color="gray.500">/ {plan.period}</Text>}
</HStack>
<List spacing={3} flex="1">
{plan.features.map((f) => (
<ListItem key={f} display="flex" alignItems="flex-start">
<Icon as={CheckIcon} color="primary.500" mt={1} mr={2} />
<Text fontSize="sm">{f}</Text>
</ListItem>
))}
</List>
<Button
as={RouterLink}
to="/demo"
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
>
{plan.cta}
</Button>
</Stack>
)
}
function RouterLinkText({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Box as={RouterLink} to={to} color="primary.500" fontWeight="medium" display="inline">
{children}
</Box>
)
}
function CheckIcon(props: React.ComponentProps<typeof Icon>) {
return (
<Icon viewBox="0 0 20 20" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.8 3.8 6.8-6.8a1 1 0 011.4 0z"
clipRule="evenodd"
/>
</Icon>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormHelperText,
FormLabel,
Heading,
HStack,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
export function Register() {
const { register } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const usernameValid = /^[a-zA-Z0-9]{3,64}$/.test(username)
const passwordValid = password.length >= 10
const passwordsMatch = password === confirm
const canSubmit = usernameValid && passwordValid && passwordsMatch
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setLoading(true)
try {
await register(username.trim(), password)
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Inscription impossible'
toast({ status: 'error', title: 'Échec de linscription', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={16} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Créer un compte</Heading>
<Text color="gray.500">Rejoignez lespace commercial Omnex.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired isInvalid={username.length > 0 && !usernameValid}>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
<FormHelperText>3 à 64 caractères alphanumériques.</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={password.length > 0 && !passwordValid}>
<FormLabel>Mot de passe</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
<FormHelperText>10 caractères minimum.</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={confirm.length > 0 && !passwordsMatch}>
<FormLabel>Confirmer le mot de passe</FormLabel>
<Input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</FormControl>
<Button
type="submit"
colorScheme="primary"
isLoading={loading}
isDisabled={!canSubmit}
>
Créer mon compte
</Button>
</Stack>
</form>
</CardBody>
</Card>
<HStack justify="center" spacing={1}>
<Text fontSize="sm" color="gray.500">
Déjà un compte ?
</Text>
<Button as={RouterLink} to="/login" variant="link" size="sm">
Se connecter
</Button>
</HStack>
</Stack>
</Container>
)
}
+51
View File
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,87 @@
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>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Spacer,
Spinner,
Stack,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
VStack,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type CodeBuySub } from '../../lib/api'
// Polling interval pour rafraîchir la liste
const POLL_MS = 10000
export function Codes() {
const toast = useToast()
const navigate = useNavigate()
const [codes, setCodes] = useState<CodeBuySub[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [username, setUsername] = useState('')
const [generatedCode, setGeneratedCode] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listCodes()
setCodes(res.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des codes impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
const createCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) {
toast({ status: 'error', title: 'Veuillez entrer un nom d\'utilisateur' })
return
}
setBusy('generate')
try {
const res = await api.createCode(username.trim())
setGeneratedCode(res.code)
setUsername('')
toast({ status: 'success', title: 'Code généré avec succès !' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Génération impossible', description: msg })
} finally {
setBusy(null)
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast({ status: 'success', title: 'Code copié dans le presse-papiers !' })
} catch (err) {
// Fallback pour les navigateurs qui bloquent clipboard
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed' // Évite les scrolls
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
toast({ status: 'success', title: 'Code copié dans le presse-papiers !' })
} else {
toast({ status: 'error', title: 'Impossible de copier. Essayez manuellement.' })
}
}
}
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Gestion des codes de souscription</Heading>
<Spacer />
</Flex>
{/* Formulaire de génération */}
<Box
mb={8}
p={6}
borderWidth="1px"
borderRadius="lg"
bg="bg-surface"
>
<form onSubmit={createCode}>
<VStack spacing={4} align="stretch">
<Stack direction={{ base: 'column', sm: 'row' }} align={{ base: 'stretch', sm: 'center' }} gap={4}>
<FormControl isRequired>
<FormLabel>Nom d\'utilisateur</FormLabel>
<Input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Entrez le nom d'utilisateur"
isDisabled={busy === 'generate'}
maxLength={64}
/>
</FormControl>
<Button
colorScheme="primary"
type="submit"
isLoading={busy === 'generate'}
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
>
Générer un code
</Button>
</Stack>
{generatedCode && (
<Box
p={4}
bg="gray.900"
borderRadius="md"
borderWidth="1px"
borderColor="whiteAlpha.200"
>
<Text fontSize="sm" color="gray.400" mb={2}>
Code généré pour <strong>{username}</strong> :
</Text>
<HStack>
<Text
fontFamily="mono"
fontSize="xl"
fontWeight="bold"
letterSpacing="widest"
>
{generatedCode}
</Text>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(generatedCode)}
>
Copier
</Button>
</HStack>
</Box>
)}
</VStack>
</form>
</Box>
{/* Liste des codes */}
{loading ? (
<Spinner />
) : codes.length === 0 ? (
<Text color="gray.500">Aucun code de souscription généré.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>ID</Th>
<Th>Utilisateur</Th>
<Th>Code</Th>
<Th>Date de création</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{codes.map((c) => (
<Tr key={c.id}>
<Td fontFamily="mono" fontSize="sm">{c.id.slice(0, 8)}...</Td>
<Td>
<Badge colorScheme="gray" px={2} py={1}>
{c.username}
</Badge>
</Td>
<Td fontFamily="mono" letterSpacing="wide">
{c.code_verif}
</Td>
<Td fontSize="sm" color="gray.400">
{new Date(c.created_at).toLocaleString('fr-FR')}
</Td>
<Td textAlign="right">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(c.code_verif)}
>
Copier
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import {
Badge,
Box,
Heading,
Spinner,
Stack,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
Alert,
AlertIcon,
} from '@chakra-ui/react'
import { api, ApiError, type Contact } from '../../lib/api'
export function Messages() {
const [messages, setMessages] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
api
.getMessage()
.then((res) => {
if (active) setMessages(res.messages)
})
.catch((err) => {
if (active) {
setError(err instanceof ApiError ? err.message : 'Erreur lors du chargement des messages.')
}
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
if (loading) {
return (
<Stack align="center" py={20}>
<Spinner />
</Stack>
)
}
if (error) {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
{error}
</Alert>
)
}
return (
<Stack spacing={6}>
<Stack spacing={1}>
<Heading size="lg">Messages de contact</Heading>
<Text color="gray.400">{messages.length} message(s) reçu(s)</Text>
</Stack>
{messages.length === 0 ? (
<Box bg="bg-surface" borderWidth="1px" borderColor="chakra-border-color" borderRadius="lg" p={8}>
<Text color="gray.400">Aucun message pour le moment.</Text>
</Box>
) : (
<Box
bg="bg-surface"
borderWidth="1px"
borderColor="chakra-border-color"
borderRadius="lg"
overflowX="auto"
>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Nom</Th>
<Th>Telegram</Th>
<Th>Sujet</Th>
<Th>Message</Th>
<Th>Reçu le</Th>
</Tr>
</Thead>
<Tbody>
{messages.map((m) => (
<Tr key={m.id}>
<Td fontWeight="medium">{m.username}</Td>
<Td>
<Badge colorScheme="blue">{m.telegram}</Badge>
</Td>
<Td>{m.sujet}</Td>
<Td maxW="320px" whiteSpace="normal">
{m.message}
</Td>
<Td whiteSpace="nowrap">
{new Date(m.created_at).toLocaleString('fr-FR')}
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
</Stack>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Button,
Flex,
Heading,
HStack,
Link,
Spacer,
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, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
// Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
export function Demos() {
const toast = useToast()
const navigate = useNavigate()
const [demos, setDemos] = useState<Demo[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toDelete, setToDelete] = useState<Demo | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listDemos()
setDemos(res.items ?? [])
} 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])
const create = async () => {
setBusy('new')
try {
await api.createDemo()
toast({ status: 'success', title: 'Démo lancée' })
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 {
await api.extendDemo(d.id)
toast({ status: 'success', title: 'Démo prolongée de 30 jours' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Prolongation impossible', description: msg })
} finally {
setBusy(null)
}
}
const confirmDestroy = async () => {
if (!toDelete) return
const d = toDelete
setBusy(d.id)
try {
await api.deleteDemo(d.id)
toast({ status: 'success', title: 'Démo détruite' })
setToDelete(null)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Destruction impossible', description: msg })
} finally {
setBusy(null)
}
}
const isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed'
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Démos</Heading>
<Spacer />
<Button colorScheme="primary" isLoading={busy === 'new'} onClick={create}>
Nouvelle démo
</Button>
</Flex>
{loading ? (
<Spinner />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo active. Lancez-en une depuis un lead ou ci-dessus.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => (
<Tr key={d.id}>
<Td fontFamily="mono">{d.namespace}</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>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
<HStack justify="flex-end">
<Button
size="sm"
variant="outline"
isDisabled={!isAlive(d.status) || busy === d.id}
onClick={() => extend(d)}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={() => setToDelete(d)}
>
Détruire
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
<ConfirmDialog
isOpen={!!toDelete}
title="Détruire la démo ?"
confirmLabel="Détruire"
isLoading={!!toDelete && busy === toDelete.id}
onConfirm={confirmDestroy}
onClose={() => setToDelete(null)}
>
La démo{' '}
<Text as="span" fontFamily="mono" fontWeight="semibold">
{toDelete?.namespace}
</Text>{' '}
et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible.
</ConfirmDialog>
</>
)
}
+112
View File
@@ -0,0 +1,112 @@
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'
export function Leads() {
const toast = useToast()
const navigate = useNavigate()
const { isAdmin } = useAuth()
const [leads, setLeads] = useState<Lead[]>([])
const [loading, setLoading] = useState(true)
const [launching, setLaunching] = useState<string | 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])
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 />
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>Reçu le</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{leads.map((l) => (
<Tr key={l.id}>
<Td fontWeight="medium">{l.company}</Td>
<Td>{l.email}</Td>
<Td>
<Badge>{l.status}</Badge>
</Td>
<Td>{formatDate(l.created_at)}</Td>
<Td textAlign="right">
{isAdmin && (
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="primary"
isLoading={launching === l.id}
onClick={() => launchDemo(l)}
>
Lancer une démo
</Button>
</Flex>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Spacer,
Spinner,
Stack,
Text,
VStack,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../../lib/api'
export function Subscription() {
const toast = useToast()
const navigate = useNavigate()
const [typeAbo, setTypeAbo] = useState<string | null>(null)
const [expiredAt, setExpiredAt] = useState<Date | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [code, setCode] = useState('')
const [showRenewForm, setShowRenewForm] = useState(false)
const load = useCallback(async () => {
try {
const me = await api.me()
setTypeAbo(me.type_abonnement ?? null)
setExpiredAt(me.expired_at ? new Date(me.expired_at) : null)
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement de l\'abonnement impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
}, [load])
const submitCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code.trim()) {
toast({ status: 'error', title: 'Veuillez entrer un code' })
return
}
setBusy(true)
try {
await api.addCode(code.trim())
toast({ status: 'success', title: 'Abonnement premium activé !' })
setCode('')
setShowRenewForm(false)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Code invalide', description: msg })
} finally {
setBusy(false)
}
}
const isPremium = typeAbo === 'premium'
const showForm = !isPremium || showRenewForm
const daysRemaining = expiredAt
? Math.ceil((expiredAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Mon abonnement</Heading>
<Spacer />
</Flex>
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
{loading ? (
<Spinner />
) : (
<VStack align="stretch" spacing={4}>
<HStack flexWrap="wrap" rowGap={2}>
<Text color="gray.400">Statut actuel :</Text>
<Badge colorScheme={isPremium ? 'purple' : 'gray'} px={2} py={1}>
{isPremium ? 'Premium' : 'Demo'}
</Badge>
{isPremium && !showRenewForm && (
<Button
size="sm"
variant="link"
ml={2}
whiteSpace="normal"
textAlign="left"
onClick={() => setShowRenewForm(true)}
>
Renouveler avec un nouveau code
</Button>
)}
</HStack>
{isPremium && expiredAt && (
<Text fontSize="sm" color={daysRemaining !== null && daysRemaining <= 5 ? 'orange.400' : 'gray.400'}>
{daysRemaining !== null && daysRemaining > 0
? `Expire dans ${daysRemaining} jour${daysRemaining > 1 ? 's' : ''} (le ${expiredAt.toLocaleDateString('fr-FR')})`
: `Expiré depuis le ${expiredAt.toLocaleDateString('fr-FR')}`}
</Text>
)}
{showForm && (
<form onSubmit={submitCode}>
<Stack direction={{ base: 'column', sm: 'row' }} align={{ base: 'stretch', sm: 'center' }} gap={4}>
<FormControl isRequired>
<FormLabel>
{isPremium ? 'Nouveau code de renouvellement' : 'Code de souscription'}
</FormLabel>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="XXXX-XXXX-XXXX-XXXX"
isDisabled={busy}
fontFamily="mono"
letterSpacing="wide"
/>
</FormControl>
<HStack flexShrink={0}>
<Button
colorScheme="primary"
type="submit"
isLoading={busy}
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
>
{isPremium ? 'Renouveler' : 'Activer'}
</Button>
{isPremium && (
<Button
variant="ghost"
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
onClick={() => {
setShowRenewForm(false)
setCode('')
}}
isDisabled={busy}
>
Annuler
</Button>
)}
</HStack>
</Stack>
</form>
)}
</VStack>
)}
</Box>
</>
)
}