@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 l’inscription', 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 l’espace 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 !</Heading>
|
||||
<Text>Votre demande a bien été 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user