+121
@@ -0,0 +1,121 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Center, Spinner } from '@chakra-ui/react'
|
||||
import { Landing } from './pages/Landing'
|
||||
import { Pricing } from './pages/Pricing'
|
||||
import { RequestDemo } from './pages/RequestDemo'
|
||||
import { Login } from './pages/Login'
|
||||
import { Register } from './pages/Register'
|
||||
import { Leads } from './pages/backoffice/Leads'
|
||||
import { Demos } from './pages/backoffice/Demos'
|
||||
import { Messages } from './pages/backoffice/Contact'
|
||||
import { Codes } from './pages/backoffice/Codes'
|
||||
import { Subscription } from './pages/backoffice/Subscription'
|
||||
import { PublicLayout } from './components/PublicLayout'
|
||||
import { BackofficeLayout } from './components/BackofficeLayout'
|
||||
import { Contact } from './pages/Contact'
|
||||
import { useAuth } from './lib/auth'
|
||||
import type { JSX } from 'react'
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated, initializing } = useAuth()
|
||||
if (initializing) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Spinner />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Réservé à l'admin : provisioning des démos.
|
||||
function RequireAdmin({ children }: { children: JSX.Element }) {
|
||||
const { isAdmin } = useAuth()
|
||||
return isAdmin ? children : <Navigate to="/app/leads" replace />
|
||||
}
|
||||
|
||||
// Accès aux leads : autorisé pour l'admin, ou pour les comptes non premium.
|
||||
function RequireLeadsAccess({ children }: { children: JSX.Element }) {
|
||||
const { isAdmin, isPremium } = useAuth()
|
||||
const canAccess = isAdmin || !isPremium
|
||||
return canAccess ? children : <Navigate to="/app/subscription" replace />
|
||||
}
|
||||
|
||||
// Redirection d'accueil du back-office selon le rôle.
|
||||
function BackofficeHome() {
|
||||
const { role } = useAuth()
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return <Navigate to="/app/demos" replace />
|
||||
case 'client':
|
||||
return <Navigate to="/app/subscription" replace />
|
||||
default:
|
||||
return <Navigate to="/app/leads" replace />
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Vitrine publique (header + footer, sans authentification) */}
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/tarifs" element={<Pricing />} />
|
||||
<Route path="/demo" element={<RequestDemo />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Back-office protégé */}
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<BackofficeLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<BackofficeHome />} />
|
||||
|
||||
<Route
|
||||
path="leads"
|
||||
element={
|
||||
<RequireLeadsAccess>
|
||||
<Leads />
|
||||
</RequireLeadsAccess>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="demos"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Demos />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="codes"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Codes />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="messages"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Messages />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route path="subscription" element={<Subscription />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
IconButton,
|
||||
Spacer,
|
||||
Stack,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { Link as RouterLink, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { ColorModeToggle } from './ColorModeToggle'
|
||||
|
||||
const HamburgerIcon = () => (
|
||||
<Box as="svg" w="20px" h="20px" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Box as="path" d="M3 12h18M3 6h18M3 18h18" />
|
||||
</Box>
|
||||
)
|
||||
|
||||
export function BackofficeLayout() {
|
||||
const { logout, isAdmin, isPremium, isClient } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const onLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const roleLabel = isAdmin ? 'Admin' : isClient ? 'Client' : 'Utilisateur'
|
||||
const roleColor = isAdmin ? 'purple' : isClient ? 'gray' : 'gray'
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg="chakra-subtle-bg">
|
||||
<Flex as="header" px={6} py={3} borderBottomWidth="1px" align="center" gap={6}>
|
||||
<Heading size="sm" as={RouterLink} to="/app">
|
||||
Omnex · {isAdmin ? 'Espace admin' : 'Espace client'}
|
||||
</Heading>
|
||||
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
|
||||
{(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>}
|
||||
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/messages">Messages</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
|
||||
<Badge colorScheme={roleColor}>{roleLabel}</Badge>
|
||||
<ColorModeToggle />
|
||||
<Button size="sm" variant="outline" onClick={onLogout}>
|
||||
Déconnexion
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack spacing={1} display={{ base: 'flex', md: 'none' }}>
|
||||
<Badge colorScheme={roleColor}>{roleLabel}</Badge>
|
||||
<ColorModeToggle />
|
||||
<IconButton
|
||||
aria-label="Ouvrir le menu"
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
icon={<HamburgerIcon />}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Menu mobile : tiroir latéral (identique à la page d'accueil) */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="xs">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bg="chakra-body-bg">
|
||||
<DrawerCloseButton size="lg" />
|
||||
<DrawerHeader borderBottomWidth="1px" fontWeight="bold" fontSize="xl">
|
||||
Omnex · {isAdmin ? 'Espace admin' : 'Espace client'}
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody py={6}>
|
||||
<Stack as="nav" spacing={1}>
|
||||
{(isAdmin || (isClient && !isPremium)) && (
|
||||
<NavItem to="/app/leads" onClick={onClose} mobile>
|
||||
Leads
|
||||
</NavItem>
|
||||
)}
|
||||
{isClient && (
|
||||
<NavItem to="/app/subscription" onClick={onClose} mobile>
|
||||
Abonnement
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavItem to="/app/messages" onClick={onClose} mobile>
|
||||
Messages
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavItem to="/app/demos" onClick={onClose} mobile>
|
||||
Démos
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavItem to="/app/codes" onClick={onClose} mobile>
|
||||
Codes
|
||||
</NavItem>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Button variant="outline" justifyContent="flex-start" onClick={onLogout}>
|
||||
Déconnexion
|
||||
</Button>
|
||||
</Stack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
to,
|
||||
children,
|
||||
onClick,
|
||||
mobile = false,
|
||||
}: {
|
||||
to: string
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
mobile?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
as={NavLink}
|
||||
to={to}
|
||||
size={mobile ? 'lg' : 'sm'}
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
_activeLink={{ fontWeight: 'bold', color: 'primary.500' }}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Icon, IconButton, useColorMode, useColorModeValue } from '@chakra-ui/react'
|
||||
|
||||
// Bouton de bascule clair / sombre.
|
||||
export function ColorModeToggle(props: { size?: string }) {
|
||||
const { toggleColorMode } = useColorMode()
|
||||
const label = useColorModeValue('Passer en mode sombre', 'Passer en mode clair')
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
title={label}
|
||||
variant="ghost"
|
||||
size={props.size ?? 'sm'}
|
||||
onClick={toggleColorMode}
|
||||
icon={useColorModeValue(<MoonIcon />, <SunIcon />)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<Icon viewBox="0 0 24 24" boxSize={5} fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M12 2v2m0 16v2M2 12h2m16 0h2M4.9 4.9l1.4 1.4m11.4 11.4l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"
|
||||
/>
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<Icon viewBox="0 0 24 24" boxSize={5} fill="currentColor">
|
||||
<path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SaasProvider } from '@saas-ui/react'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
|
||||
function renderDialog(props: Partial<React.ComponentProps<typeof ConfirmDialog>> = {}) {
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SaasProvider>
|
||||
<ConfirmDialog
|
||||
isOpen
|
||||
title="Détruire la démo ?"
|
||||
confirmLabel="Détruire"
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
{...props}
|
||||
/>
|
||||
</SaasProvider>,
|
||||
)
|
||||
return { onConfirm, onClose }
|
||||
}
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('appelle onConfirm au clic sur le bouton de confirmation', async () => {
|
||||
const { onConfirm } = renderDialog()
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /détruire/i }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('appelle onClose au clic sur Annuler', async () => {
|
||||
const { onClose } = renderDialog()
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /annuler/i }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ne rend pas le contenu quand il est fermé', () => {
|
||||
renderDialog({ isOpen: false })
|
||||
expect(screen.queryByRole('button', { name: /détruire/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
children?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
confirmColorScheme?: string
|
||||
isLoading?: boolean
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Modal de confirmation réutilisable pour les actions sensibles.
|
||||
// Basé sur AlertDialog (focus initial sur "Annuler", fermeture au clic extérieur / Échap).
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
confirmLabel = 'Confirmer',
|
||||
cancelLabel = 'Annuler',
|
||||
confirmColorScheme = 'red',
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
motionPreset="slideInBottom"
|
||||
>
|
||||
<AlertDialogOverlay backdropFilter="blur(2px)">
|
||||
<AlertDialogContent borderRadius="xl">
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{title}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody color="gray.600">{children}</AlertDialogBody>
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button ref={cancelRef} onClick={onClose} variant="ghost" isDisabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button colorScheme={confirmColorScheme} onClick={onConfirm} isLoading={isLoading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Box, Container, Divider, HStack, Link, SimpleGrid, Stack, Text } from '@chakra-ui/react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<Box as="footer" borderTopWidth="1px" mt={20} bg="chakra-subtle-bg">
|
||||
<Container maxW="container.lg" py={12}>
|
||||
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={8}>
|
||||
<Stack spacing={3}>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
Omnex
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Plateforme de gestion de commandes & livraison, déployable en démo isolée en un clic.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FooterCol title="Produit">
|
||||
<FooterLink to="/">Présentation</FooterLink>
|
||||
<FooterLink to="/tarifs">Tarifs</FooterLink>
|
||||
<FooterLink to="/demo">Demander une démo</FooterLink>
|
||||
</FooterCol>
|
||||
|
||||
<FooterCol title="Ressources">
|
||||
<FooterExt href="#">Documentation</FooterExt>
|
||||
<FooterExt href="#">Statut</FooterExt>
|
||||
<FooterExt href="#">Sécurité</FooterExt>
|
||||
</FooterCol>
|
||||
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={8} />
|
||||
|
||||
<HStack justify="space-between" flexWrap="wrap" spacing={4}>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
© {new Date().getFullYear()} Omnex. Tous droits réservés.
|
||||
</Text>
|
||||
<HStack spacing={6} fontSize="sm" color="gray.500">
|
||||
<FooterExt href="#">Mentions légales</FooterExt>
|
||||
<FooterExt href="#">Confidentialité</FooterExt>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterCol({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Text fontWeight="semibold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
{title}
|
||||
</Text>
|
||||
{children}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link as={RouterLink} to={to} fontSize="sm" color="gray.600" _hover={{ color: 'primary.500' }}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterExt({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link href={href} fontSize="sm" color="gray.600" _hover={{ color: 'primary.500' }}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Stack,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { Link as RouterLink, NavLink } from 'react-router-dom'
|
||||
import { ColorModeToggle } from './ColorModeToggle'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Accueil', end: true },
|
||||
{ to: '/tarifs', label: 'Tarifs', end: false },
|
||||
{ to: '/contact', label: 'Contact', end: false },
|
||||
]
|
||||
|
||||
// Icône Hamburger SVG
|
||||
const HamburgerIcon = () => (
|
||||
<Box as="svg" w="24px" h="24px" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<Box as="path" d="M3 12h18M3 6h18M3 18h18" />
|
||||
</Box>
|
||||
)
|
||||
|
||||
export function Header() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex="sticky"
|
||||
bg="chakra-body-bg"
|
||||
borderBottomWidth="1px"
|
||||
backdropFilter="saturate(180%) blur(6px)"
|
||||
>
|
||||
<Container maxW="container.lg">
|
||||
<Flex h={16} align="center" justify="space-between">
|
||||
<HStack spacing={8}>
|
||||
<Box as={RouterLink} to="/" fontWeight="bold" fontSize="xl" letterSpacing="tight">
|
||||
Omnex
|
||||
</Box>
|
||||
<HStack as="nav" spacing={1} display={{ base: 'none', md: 'flex' }}>
|
||||
{navLinks.map((l) => (
|
||||
<NavItem key={l.to} to={l.to} end={l.end}>
|
||||
{l.label}
|
||||
</NavItem>
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
|
||||
<ColorModeToggle />
|
||||
<Button as={RouterLink} to="/login" variant="ghost" size="sm">
|
||||
Espace commercial
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/demo" colorScheme="primary" size="sm">
|
||||
Demander une démo
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={1} display={{ base: 'flex', md: 'none' }}>
|
||||
<ColorModeToggle />
|
||||
<IconButton
|
||||
aria-label="Ouvrir le menu"
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
icon={<HamburgerIcon />}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* Menu mobile : tiroir latéral */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="xs">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bg="chakra-body-bg">
|
||||
<DrawerCloseButton size="lg" />
|
||||
<DrawerHeader borderBottomWidth="1px" fontWeight="bold" fontSize="xl">
|
||||
Omnex
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody py={6}>
|
||||
<Stack as="nav" spacing={1}>
|
||||
{navLinks.map((l) => (
|
||||
<NavItem key={l.to} to={l.to} end={l.end} onClick={onClose} mobile>
|
||||
{l.label}
|
||||
</NavItem>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/login"
|
||||
variant="outline"
|
||||
justifyContent="flex-start"
|
||||
onClick={onClose}
|
||||
>
|
||||
Espace commercial
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/demo"
|
||||
colorScheme="primary"
|
||||
justifyContent="flex-start"
|
||||
onClick={onClose}
|
||||
>
|
||||
Demander une démo
|
||||
</Button>
|
||||
</Stack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
to,
|
||||
end,
|
||||
children,
|
||||
onClick,
|
||||
mobile = false,
|
||||
}: {
|
||||
to: string
|
||||
end: boolean
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
mobile?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
as={NavLink}
|
||||
to={to}
|
||||
end={end}
|
||||
size={mobile ? 'lg' : 'sm'}
|
||||
variant="ghost"
|
||||
justifyContent={mobile ? 'flex-start' : 'center'}
|
||||
onClick={onClick}
|
||||
_activeLink={{ fontWeight: 'bold', color: 'primary.500' }}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Box, Flex } from '@chakra-ui/react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
import { Footer } from './Footer'
|
||||
|
||||
// Layout des pages publiques (vitrine) : header + contenu + footer.
|
||||
export function PublicLayout() {
|
||||
return (
|
||||
<Flex direction="column" minH="100vh">
|
||||
<Header />
|
||||
<Box as="main" flex="1">
|
||||
<Outlet />
|
||||
</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Client HTTP vers l'API Omnex.
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
const TOKEN_KEY = 'omnex.token'
|
||||
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = getToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const msg = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
throw new ApiError(res.status, msg.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type Role = 'admin' | 'client'
|
||||
|
||||
export interface Lead {
|
||||
id: string
|
||||
company: string
|
||||
email: string
|
||||
message: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type DemoStatus =
|
||||
| 'pending'
|
||||
| 'provisioning'
|
||||
| 'ready'
|
||||
| 'expiring'
|
||||
| 'expired'
|
||||
| 'failed'
|
||||
|
||||
export interface Demo {
|
||||
id: string
|
||||
lead_id?: string
|
||||
status: DemoStatus
|
||||
namespace: string
|
||||
url: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface CodeBuySub {
|
||||
id: string
|
||||
username: string
|
||||
code_verif: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string
|
||||
username: string
|
||||
telegram: string
|
||||
sujet: string
|
||||
message: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// --- Endpoints ---
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/login', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
register: (username: string, password: string) =>
|
||||
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/register', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
me: () =>
|
||||
request<{
|
||||
user_id: string
|
||||
username: string
|
||||
role: Role
|
||||
type_abonnement: string
|
||||
expired_at: string
|
||||
}>('GET', '/auth/me'), logout: () => request<{ status: string }>('POST', '/auth/logout'),
|
||||
|
||||
createLead: (telegram: string, message: string) =>
|
||||
request<Lead>('POST', '/leads', { telegram, message }),
|
||||
listLeads: () => request<{ items: Lead[] }>('GET', '/leads'),
|
||||
setLeadStatus: (id: string, status: string) =>
|
||||
request<Lead>('PATCH', `/leads/${id}/status`, { status }),
|
||||
|
||||
listDemos: () => request<{ items: Demo[] }>('GET', '/demos'),
|
||||
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
||||
createDemo: (leadId?: string) =>
|
||||
request<Demo>('POST', '/demos', leadId ? { lead_id: leadId } : {}),
|
||||
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
|
||||
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),
|
||||
|
||||
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
||||
createCode: (username: string) =>
|
||||
request<{ code: string }>('POST', '/codes', { username }),
|
||||
addCode: (code: string) =>
|
||||
request<{ success: string }>('POST', '/subscription', { code_verif: code }),
|
||||
sendMessage: (username: string, telegram: string, sujet: string, message: string) =>
|
||||
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
||||
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { api, clearToken, getToken, setToken, type Role } from './api'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
isClient: boolean
|
||||
isPremium: boolean
|
||||
role: Role | null
|
||||
typeAbo: string | null
|
||||
initializing: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refreshAbo: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setTok] = useState<string | null>(getToken())
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
const [typeAbo, setTypeAbo] = useState<string | null>(null)
|
||||
const [initializing, setInitializing] = useState<boolean>(!!getToken())
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setInitializing(false)
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
api
|
||||
.me()
|
||||
.then((me) => {
|
||||
if (active) {
|
||||
setRole(me.role)
|
||||
setTypeAbo(me.type_abonnement)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
clearToken()
|
||||
setTok(null)
|
||||
setRole(null)
|
||||
setTypeAbo(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setInitializing(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await api.login(username, password)
|
||||
setToken(res.token)
|
||||
setTok(res.token)
|
||||
setRole(res.role)
|
||||
const me = await api.me()
|
||||
setTypeAbo(me.type_abonnement)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
const res = await api.register(username, password)
|
||||
setToken(res.token)
|
||||
setTok(res.token)
|
||||
setRole(res.role)
|
||||
const me = await api.me()
|
||||
setTypeAbo(me.type_abonnement)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout()
|
||||
} finally {
|
||||
clearToken()
|
||||
setTok(null)
|
||||
setRole(null)
|
||||
setTypeAbo(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// À rappeler après api.addCode(...) pour rafraîchir isPremium sans recharger la page.
|
||||
const refreshAbo = useCallback(async () => {
|
||||
const me = await api.me()
|
||||
setTypeAbo(me.type_abonnement)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({
|
||||
isAuthenticated: !!token,
|
||||
isAdmin: role === 'admin',
|
||||
isClient: role === 'client',
|
||||
isPremium: typeAbo === 'premium',
|
||||
role,
|
||||
typeAbo,
|
||||
initializing,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAbo,
|
||||
}),
|
||||
[token, role, typeAbo, initializing, login, register, logout, refreshAbo],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth doit être utilisé dans <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { statusColor, statusLabel, timeRemaining } from './format'
|
||||
|
||||
describe('format', () => {
|
||||
it('mappe les statuts vers un libellé FR', () => {
|
||||
expect(statusLabel('ready')).toBe('Active')
|
||||
expect(statusLabel('provisioning')).toBe('Déploiement…')
|
||||
expect(statusLabel('expired')).toBe('Expirée')
|
||||
})
|
||||
|
||||
it('associe une couleur cohérente au statut', () => {
|
||||
expect(statusColor('ready')).toBe('green')
|
||||
expect(statusColor('failed')).toBe('red')
|
||||
expect(statusColor('expiring')).toBe('orange')
|
||||
})
|
||||
|
||||
it('calcule le temps restant en jours/heures', () => {
|
||||
const now = Date.parse('2026-07-01T00:00:00Z')
|
||||
const in30d = '2026-07-31T00:00:00Z'
|
||||
expect(timeRemaining(in30d, now)).toBe('30 j 0 h')
|
||||
})
|
||||
|
||||
it('renvoie "expirée" quand l’échéance est passée', () => {
|
||||
const now = Date.parse('2026-07-10T00:00:00Z')
|
||||
expect(timeRemaining('2026-07-01T00:00:00Z', now)).toBe('expirée')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { DemoStatus } from './api'
|
||||
|
||||
// Couleur de badge Chakra par statut de démo.
|
||||
export function statusColor(s: DemoStatus): string {
|
||||
switch (s) {
|
||||
case 'ready':
|
||||
return 'green'
|
||||
case 'provisioning':
|
||||
case 'pending':
|
||||
return 'blue'
|
||||
case 'expiring':
|
||||
return 'orange'
|
||||
case 'failed':
|
||||
return 'red'
|
||||
case 'expired':
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
// Libellé FR du statut.
|
||||
export function statusLabel(s: DemoStatus): string {
|
||||
const map: Record<DemoStatus, string> = {
|
||||
pending: 'En attente',
|
||||
provisioning: 'Déploiement…',
|
||||
ready: 'Active',
|
||||
expiring: 'Suppression…',
|
||||
expired: 'Expirée',
|
||||
failed: 'Échec',
|
||||
}
|
||||
return map[s] ?? s
|
||||
}
|
||||
|
||||
// Temps restant avant expiration, formaté (ex. "29 j 4 h").
|
||||
export function timeRemaining(expiresAt: string, now: number = Date.now()): string {
|
||||
const ms = new Date(expiresAt).getTime() - now
|
||||
if (ms <= 0) return 'expirée'
|
||||
const days = Math.floor(ms / 86_400_000)
|
||||
const hours = Math.floor((ms % 86_400_000) / 3_600_000)
|
||||
if (days > 0) return `${days} j ${hours} h`
|
||||
const mins = Math.floor((ms % 3_600_000) / 60_000)
|
||||
return `${hours} h ${mins} min`
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ColorModeScript } from '@chakra-ui/react'
|
||||
import { SaasProvider } from '@saas-ui/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './lib/auth'
|
||||
import { theme } from './theme'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
|
||||
<SaasProvider theme={theme}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</SaasProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Chakra/Saas UI ont besoin de ces API navigateur absentes de jsdom.
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
})
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
|
||||
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo
|
||||
@@ -0,0 +1,118 @@
|
||||
import { extendTheme, type ThemeConfig } from '@chakra-ui/react'
|
||||
import { theme as saasTheme } from '@saas-ui/react'
|
||||
|
||||
const config: ThemeConfig = {
|
||||
initialColorMode: 'system',
|
||||
useSystemColorMode: false,
|
||||
}
|
||||
|
||||
// Thème Omnex Ultra-Sombre : étend Saas UI avec un dark mode extrêmement sombre
|
||||
// Fond noir pur, surfaces très sombres, texte très contrasté
|
||||
export const theme = extendTheme(
|
||||
{
|
||||
config,
|
||||
colors: {
|
||||
// Override des couleurs de base pour un look ultra-sombre
|
||||
black: '#000000',
|
||||
gray: {
|
||||
50: '#f7f7f8',
|
||||
100: '#e8e8ea',
|
||||
200: '#c5c5c9',
|
||||
300: '#a2a2a9',
|
||||
400: '#7f7f88',
|
||||
500: '#5c5c66',
|
||||
600: '#43434c',
|
||||
700: '#2a2a33',
|
||||
800: '#15151b',
|
||||
900: '#0a0a0f',
|
||||
},
|
||||
},
|
||||
semanticTokens: {
|
||||
colors: {
|
||||
// Fond principal (body) : noir pur
|
||||
'chakra-body-bg': { _light: 'white', _dark: '#000000' },
|
||||
// Fond secondaire (layouts, footer) : presque noir
|
||||
'chakra-subtle-bg': { _light: 'gray.50', _dark: '#050508' },
|
||||
// Surface élevée (cartes) : très sombre mais distinct
|
||||
'bg-surface': { _light: 'white', _dark: '#08080c' },
|
||||
// Texte principal : blanc très clair pour contraste maximal
|
||||
'chakra-body-text': { _light: 'gray.800', _dark: '#f5f5f7' },
|
||||
// Bordures : très discrètes, presque invisibles
|
||||
'chakra-border-color': { _light: 'gray.200', _dark: 'whiteAlpha.100' },
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
body: {
|
||||
bg: 'chakra-body-bg',
|
||||
color: 'chakra-body-text',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
// Assombrir les composants Saas UI
|
||||
Card: {
|
||||
baseStyle: {
|
||||
container: {
|
||||
bg: 'bg-surface',
|
||||
borderColor: 'whiteAlpha.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
Button: {
|
||||
baseStyle: {
|
||||
_dark: {
|
||||
bg: 'gray.800',
|
||||
_hover: { bg: 'gray.700' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Input: {
|
||||
baseStyle: {
|
||||
field: {
|
||||
_dark: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'whiteAlpha.200',
|
||||
_focus: {
|
||||
borderColor: 'whiteAlpha.400',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Select: {
|
||||
baseStyle: {
|
||||
field: {
|
||||
_dark: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'whiteAlpha.200',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Textarea: {
|
||||
baseStyle: {
|
||||
_dark: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'whiteAlpha.200',
|
||||
},
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
baseStyle: {
|
||||
overlay: {
|
||||
_dark: {
|
||||
bg: 'blackAlpha.800',
|
||||
},
|
||||
},
|
||||
content: {
|
||||
_dark: {
|
||||
bg: '#08080c',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
saasTheme,
|
||||
)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user