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

This commit is contained in:
Nuxgrid
2026-07-27 19:13:56 +02:00
parent bb6c425840
commit 07db8a2ae2
39 changed files with 7534 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.*
+1
View File
@@ -0,0 +1 @@
node_modules
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1
# --- Build (Vite) ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Même origine : l'API est proxifiée par nginx (cf. nginx.conf), donc URL relative.
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# --- Runtime (nginx) ---
FROM nginx:1.31-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+87
View File
@@ -0,0 +1,87 @@
# Déploiement local du site Omnex (control-plane).
# Postgres = base de données (GORM)
# Redis = sessions utilisateurs
# API = backend Go/Gin (avec worker Helm pour les démos)
# Web = SPA React/Vite servie par nginx (proxy /api → backend)
#
# Démarrage : cp .env.example .env && docker compose up --build
# Site : http://localhost:3000
# API : http://localhost:8080/healthz
#
# Pour les démos : les charts Helm doivent être dans ./deploy/chart-gestion/
# Le kubeconfig est monté automatiquement depuis ~/.kube/config
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-omnex}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-omnex}
POSTGRES_DB: ${POSTGRES_DB:-omnex}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-omnex} -d ${POSTGRES_DB:-omnex}"]
interval: 5s
timeout: 3s
retries: 10
# Pas de port exposé : accès interne uniquement (défense en profondeur).
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-omnexredis}", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-omnexredis}", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: ./control-plane/api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
OMNEX_ENV: ${OMNEX_ENV:-dev}
OMNEX_ADDR: ":8080"
OMNEX_JWT_SECRET: ${OMNEX_JWT_SECRET}
OMNEX_ALLOWED_ORIGINS: ${OMNEX_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
OMNEX_DATABASE_URL: "host=postgres user=${POSTGRES_USER:-omnex} password=${POSTGRES_PASSWORD:-omnex} dbname=${POSTGRES_DB:-omnex} port=5432 sslmode=disable"
OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0"
OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin}
OMNEX_SEED_PASSWORD: ${OMNEX_SEED_PASSWORD}
OMNEX_DEMO_DOMAIN: ${OMNEX_DEMO_DOMAIN:-demo.omnex.app}
FRONTEND_IMAGE_APP: ${FRONTEND_IMAGE_APP:-xor1234/frontend-mln:latest}
BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest}
# Kubeconfig pour accéder au cluster Kubernetes (optionnel - si non fourni, utilise InClusterConfig)
KUBECONFIG: /kubeconfig/config
volumes:
- ./deploy/chart-gestion:/charts:ro
- /home/xor_fakers/.kube/config:/kubeconfig/config:ro # ← Fichier, pas répertoire
ports:
- "8080:8080"
web:
build:
context: ./web
args:
# Vide = même origine : nginx proxifie /api vers le backend.
VITE_API_URL: ""
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "3000:80"
volumes:
pgdata:
redisdata:
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Résolveur DNS interne de Docker : résolution du backend à l'exécution,
# pour que nginx démarre même si le service "api" arrive un peu après.
resolver 127.0.0.11 valid=30s ipv6=off;
# En-têtes de sécurité (défense en profondeur, en plus du backend/WAF).
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# API proxifiée vers le backend Go (même origine → pas de CORS).
# proxy_pass sans URI (variable) => l'URI d'origine est transmise telle quelle.
location /api/ {
set $api_upstream api:8080;
proxy_pass http://$api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /healthz {
set $api_upstream api:8080;
proxy_pass http://$api_upstream;
}
# SPA : fallback vers index.html pour le routage côté client.
location / {
try_files $uri $uri/ /index.html;
}
}
+4513
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "omnex-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@chakra-ui/react": "^2.10.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@saas-ui/react": "^2.11.0",
"framer-motion": "^11.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}
+121
View File
@@ -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>
)
}
+157
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+42
View File
@@ -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()
})
})
+65
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+158
View File
@@ -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>
)
}
+17
View File
@@ -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>
)
}
+133
View File
@@ -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'),
}
+124
View File
@@ -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
}
+27
View File
@@ -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')
})
})
+51
View File
@@ -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',
})
}
+21
View File
@@ -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>,
)
+211
View File
@@ -0,0 +1,211 @@
import { useState, type FormEvent } from 'react'
import {
Box,
Button,
Container,
Divider,
Flex,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
Input,
Link,
Stack,
Text,
Textarea,
SimpleGrid,
useToast,
} from '@chakra-ui/react'
import { api, ApiError } from '../lib/api'
interface ContactForm {
name: string
telegram: string
subject: string
message: string
}
const initialForm: ContactForm = {
name: '',
telegram: '',
subject: '',
message: '',
}
// Remplace par le vrai handle / lien de contact Telegram d'Omnex.
const TELEGRAM_CONTACT_URL = 'https://t.me/OMNEX_CORP'
const TelegramIcon = () => (
<Box as="svg" w="20px" h="20px" viewBox="0 0 24 24" fill="currentColor">
<Box
as="path"
d="M21.05 3.79a1.5 1.5 0 0 0-1.53-.25L2.6 10.1a1.5 1.5 0 0 0 .07 2.8l4.44 1.48 1.7 5.46a1.5 1.5 0 0 0 2.5.6l2.4-2.32 4.36 3.2a1.5 1.5 0 0 0 2.38-.86l3.1-14.9a1.5 1.5 0 0 0-.5-1.77ZM8.9 13.6l8.9-6.2-7.2 7.4-.3 3.2-1.4-4.4Z"
/>
</Box>
)
export function Contact() {
const toast = useToast()
const [form, setForm] = useState<ContactForm>(initialForm)
const [errors, setErrors] = useState<Partial<Record<keyof ContactForm, string>>>({})
const [submitting, setSubmitting] = useState(false)
const handleChange =
(field: keyof ContactForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
const validate = (): boolean => {
const nextErrors: Partial<Record<keyof ContactForm, string>> = {}
if (!form.name.trim()) nextErrors.name = 'Le nom est requis.'
if (!form.telegram.trim()) {
nextErrors.telegram = "L'email est requis."
}
if (!form.subject.trim()) nextErrors.subject = 'Le sujet est requis.'
if (!form.message.trim()) nextErrors.message = 'Le message est requis.'
setErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!validate()) return
setSubmitting(true)
try {
await api.sendMessage(form.name, form.telegram, form.subject, form.message)
toast({
title: 'Message envoyé',
description: 'Nous vous répondrons dans les plus brefs délais.',
status: 'success',
duration: 5000,
isClosable: true,
})
setForm(initialForm)
setErrors({})
} catch (err) {
const description =
err instanceof ApiError ? err.message : 'Merci de réessayer dans quelques instants.'
toast({
title: "Erreur lors de l'envoi",
description,
status: 'error',
duration: 5000,
isClosable: true,
})
} finally {
setSubmitting(false)
}
}
return (
<Box bg="chakra-subtle-bg" py={{ base: 12, md: 20 }}>
<Container maxW="container.md">
<Stack spacing={3} mb={10} textAlign="center">
<Heading size="xl">Contactez-nous</Heading>
<Text color="gray.400" fontSize="lg">
Une question, une démo à planifier, un projet ? Écrivez-nous.
</Text>
</Stack>
<Flex justify="center" mb={10}>
<Button
as={Link}
href={TELEGRAM_CONTACT_URL}
isExternal
leftIcon={<TelegramIcon />}
colorScheme="telegram"
size="lg"
w={{ base: 'full', sm: 'auto' }}
whiteSpace="normal"
_hover={{ textDecoration: 'none' }}
>
Nous contacter sur Telegram
</Button>
</Flex>
<Flex align="center" mb={10} gap={4}>
<Divider />
<Text fontSize="sm" color="gray.500" whiteSpace="nowrap">
ou par formulaire
</Text>
<Divider />
</Flex>
<Box
as="form"
onSubmit={handleSubmit}
bg="bg-surface"
borderWidth="1px"
borderColor="chakra-border-color"
borderRadius="xl"
p={{ base: 6, md: 10 }}
boxShadow="lg"
>
<Stack spacing={6}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>Nom</FormLabel>
<Input
value={form.name}
onChange={handleChange('name')}
placeholder="Votre nom"
size="lg"
/>
<FormErrorMessage>{errors.name}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.telegram}>
<FormLabel>Telegram</FormLabel>
<Input
type="email"
value={form.telegram}
onChange={handleChange('telegram')}
placeholder="vous@entreprise.com"
size="lg"
/>
<FormErrorMessage>{errors.telegram}</FormErrorMessage>
</FormControl>
</SimpleGrid>
<FormControl isInvalid={!!errors.subject}>
<FormLabel>Sujet</FormLabel>
<Input
value={form.subject}
onChange={handleChange('subject')}
placeholder="Demande de démo, question tarifaire..."
size="lg"
/>
<FormErrorMessage>{errors.subject}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.message}>
<FormLabel>Message</FormLabel>
<Textarea
value={form.message}
onChange={handleChange('message')}
placeholder="Décrivez votre besoin..."
rows={6}
resize="vertical"
/>
<FormErrorMessage>{errors.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="primary"
size="lg"
alignSelf={{ base: 'stretch', md: 'flex-start' }}
isLoading={submitting}
loadingText="Envoi..."
>
Envoyer le message
</Button>
</Stack>
</Box>
</Container>
</Box>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Box, Button, Container, Heading, SimpleGrid, Stack, Text } from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
const features = [
{ title: 'Gestion de commandes', desc: 'Client, admin, cabine, livreur — un flux complet de bout en bout.' },
{ title: 'Livraison temps réel', desc: 'GPS TomTom, auto-assignation des livreurs, ETA et navigation.' },
{ title: 'Paiements & notifications', desc: 'NowPayments (crypto), Telegram.' },
{ title: 'Sécurisé par design', desc: 'WAF ModSecurity/Coraza, TLS, JWT, isolation par démo.' },
]
export function Landing() {
return (
<Box>
{/* Hero */}
<Box bgGradient="linear(to-b, blackAlpha.50, transparent)" py={{ base: 16, md: 24 }}>
<Container maxW="container.lg">
<Stack spacing={6} textAlign="center" align="center">
<Heading size="2xl">La plateforme de gestion de commandes & livraison</Heading>
<Text fontSize="xl" color="gray.600" maxW="2xl">
Testez la solution complète en conditions réelles. Une démo isolée, déployée en un
clic, disponible pendant 30 jours.
</Text>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={4} w={{ base: 'full', sm: 'auto' }}>
<Button
as={RouterLink}
to="/demo"
colorScheme="primary"
size="lg"
w={{ base: 'full', sm: 'auto' }}
>
Demander une démo
</Button>
<Button
as={RouterLink}
to="/tarifs"
variant="outline"
size="lg"
w={{ base: 'full', sm: 'auto' }}
>
Voir les tarifs
</Button>
</Stack>
</Stack>
</Container>
</Box>
{/* Fonctionnalités */}
<Container maxW="container.lg" py={16}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{features.map((f) => (
<Box key={f.title} p={6} borderWidth="1px" borderRadius="lg">
<Heading size="md" mb={2}>{f.title}</Heading>
<Text color="gray.600">{f.desc}</Text>
</Box>
))}
</SimpleGrid>
</Container>
</Box>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
export function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await login(username.trim(), password)
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
toast({ status: 'error', title: 'Échec de connexion', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={20} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Espace commercial</Heading>
<Text color="gray.500">Connectez-vous pour gérer les démos.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Mot de passe</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormControl>
<Button type="submit" colorScheme="primary" isLoading={loading}>
Se connecter
</Button>
</Stack>
</form>
</CardBody>
</Card>
<HStack justify="center" spacing={1}>
<Text fontSize="sm" color="gray.500">
Pas encore de compte ?
</Text>
<Button as={RouterLink} to="/register" variant="link" size="sm">
Créer un compte
</Button>
</HStack>
<Button as={RouterLink} to="/" variant="link" size="sm">
Retour au site
</Button>
</Stack>
</Container>
)
}
+178
View File
@@ -0,0 +1,178 @@
import {
Badge,
Box,
Button,
Container,
Heading,
HStack,
Icon,
List,
ListItem,
SimpleGrid,
Stack,
Text,
} from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
interface Plan {
name: string
price: string
period: string
description: string
features: string[]
cta: string
highlighted?: boolean
}
const plans: Plan[] = [
{
name: 'Démo',
price: 'Gratuit',
period: '30 jours',
description: 'Une instance isolée et complète pour évaluer la solution.',
features: [
'Plateforme complète en conditions réelles',
'Environnement dédié et isolé',
'Données de démonstration pré-remplies',
'Disponible 30 jours',
'Accompagnement commercial',
],
cta: 'Demander une démo',
},
{
name: 'Pro',
price: 'Sur devis',
period: 'par mois',
description: 'Pour déployer la plateforme en production sur votre activité.',
features: [
'Tout ce qui est inclus dans Démo',
'Déploiement production dédié',
'WAF, TLS, sauvegardes',
'GPS, paiements et notifications',
'Support prioritaire',
],
cta: 'Nous contacter',
highlighted: true,
},
{
name: 'Entreprise',
price: 'Sur mesure',
period: '',
description: 'Multi-sites, SLA et intégrations spécifiques.',
features: [
'Tout ce qui est inclus dans Pro',
'Haute disponibilité multi-régions',
'SLA et supervision 24/7',
'Intégrations sur mesure',
'Accompagnement dédié',
],
cta: 'Nous contacter',
},
]
export function Pricing() {
return (
<Container maxW="container.lg" py={{ base: 12, md: 20 }}>
<Stack spacing={4} textAlign="center" mb={12} align="center">
<Heading size="2xl">Tarifs</Heading>
<Text fontSize="lg" color="gray.600" maxW="2xl">
Commencez par une démo gratuite de 30 jours, puis passez en production quand vous êtes prêt.
</Text>
</Stack>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8} alignItems="stretch">
{plans.map((plan) => (
<PlanCard key={plan.name} plan={plan} />
))}
</SimpleGrid>
<Text textAlign="center" color="gray.500" mt={10} fontSize="sm">
Besoin d'un devis précis ? <RouterLinkText to="/demo">Demandez une démo</RouterLinkText> — un
commercial vous recontacte.
</Text>
</Container>
)
}
function PlanCard({ plan }: { plan: Plan }) {
return (
<Stack
spacing={6}
p={8}
borderWidth={plan.highlighted ? '2px' : '1px'}
borderColor={plan.highlighted ? 'primary.500' : 'inherit'}
borderRadius="xl"
position="relative"
boxShadow={plan.highlighted ? 'lg' : 'sm'}
bg="bg-surface"
>
{plan.highlighted && (
<Badge
colorScheme="primary"
position="absolute"
top={-3}
left="50%"
transform="translateX(-50%)"
px={3}
py={1}
borderRadius="full"
>
Le plus choisi
</Badge>
)}
<Box>
<Heading size="md">{plan.name}</Heading>
<Text color="gray.500" mt={1} fontSize="sm">
{plan.description}
</Text>
</Box>
<HStack align="baseline" spacing={2}>
<Text fontSize="3xl" fontWeight="bold">
{plan.price}
</Text>
{plan.period && <Text color="gray.500">/ {plan.period}</Text>}
</HStack>
<List spacing={3} flex="1">
{plan.features.map((f) => (
<ListItem key={f} display="flex" alignItems="flex-start">
<Icon as={CheckIcon} color="primary.500" mt={1} mr={2} />
<Text fontSize="sm">{f}</Text>
</ListItem>
))}
</List>
<Button
as={RouterLink}
to="/demo"
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
>
{plan.cta}
</Button>
</Stack>
)
}
function RouterLinkText({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Box as={RouterLink} to={to} color="primary.500" fontWeight="medium" display="inline">
{children}
</Box>
)
}
function CheckIcon(props: React.ComponentProps<typeof Icon>) {
return (
<Icon viewBox="0 0 20 20" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.8 3.8 6.8-6.8a1 1 0 011.4 0z"
clipRule="evenodd"
/>
</Icon>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormHelperText,
FormLabel,
Heading,
HStack,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
export function Register() {
const { register } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const usernameValid = /^[a-zA-Z0-9]{3,64}$/.test(username)
const passwordValid = password.length >= 10
const passwordsMatch = password === confirm
const canSubmit = usernameValid && passwordValid && passwordsMatch
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setLoading(true)
try {
await register(username.trim(), password)
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Inscription impossible'
toast({ status: 'error', title: 'Échec de linscription', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={16} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Créer un compte</Heading>
<Text color="gray.500">Rejoignez lespace commercial Omnex.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired isInvalid={username.length > 0 && !usernameValid}>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
<FormHelperText>3 à 64 caractères alphanumériques.</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={password.length > 0 && !passwordValid}>
<FormLabel>Mot de passe</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
<FormHelperText>10 caractères minimum.</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={confirm.length > 0 && !passwordsMatch}>
<FormLabel>Confirmer le mot de passe</FormLabel>
<Input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
/>
</FormControl>
<Button
type="submit"
colorScheme="primary"
isLoading={loading}
isDisabled={!canSubmit}
>
Créer mon compte
</Button>
</Stack>
</form>
</CardBody>
</Card>
<HStack justify="center" spacing={1}>
<Text fontSize="sm" color="gray.500">
Déjà un compte ?
</Text>
<Button as={RouterLink} to="/login" variant="link" size="sm">
Se connecter
</Button>
</HStack>
</Stack>
</Container>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SaasProvider } from '@saas-ui/react'
import { MemoryRouter } from 'react-router-dom'
import { RequestDemo } from './RequestDemo'
function renderPage() {
return render(
<SaasProvider>
<MemoryRouter>
<RequestDemo />
</MemoryRouter>
</SaasProvider>,
)
}
describe('RequestDemo', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('affiche le formulaire de demande de démo', () => {
renderPage()
expect(screen.getByRole('heading', { name: /demander une démo/i })).toBeInTheDocument()
expect(screen.getByLabelText(/entreprise/i)).toBeInTheDocument()
})
it('envoie le lead et affiche la confirmation', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: '1', company: 'ACME', email: 'a@acme.io', status: 'new' }),
})
vi.stubGlobal('fetch', fetchMock)
renderPage()
const user = userEvent.setup()
await user.type(screen.getByLabelText(/entreprise/i), 'ACME')
await user.type(screen.getByLabelText(/email professionnel/i), 'a@acme.io')
await user.click(screen.getByRole('button', { name: /envoyer ma demande/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /merci/i })).toBeInTheDocument(),
)
expect(fetchMock).toHaveBeenCalledOnce()
})
})
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormLabel,
Heading,
Input,
Stack,
Text,
Textarea,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
export function RequestDemo() {
const toast = useToast()
const [telegram, setTelegram] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await api.createLead(telegram.trim(), message.trim())
setSent(true)
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Envoi impossible'
toast({ status: 'error', title: 'Échec', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="md" py={16}>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Demander une démo</Heading>
<Text color="gray.500">
Laissez vos coordonnées, un commercial déploiera votre démo dédiée (30 jours).
</Text>
</Box>
{sent ? (
<Card>
<CardBody>
<Stack spacing={4} textAlign="center">
<Heading size="md">Merci&nbsp;!</Heading>
<Text>Votre demande a bien é enregistrée. Nous revenons vers vous rapidement.</Text>
<Button as={RouterLink} to="/" variant="outline">
Retour à l'accueil
</Button>
</Stack>
</CardBody>
</Card>
) : (
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>Telegram</FormLabel>
<Input value={telegram} onChange={(e) => setTelegram(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Message (optionnel)</FormLabel>
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} rows={4} />
</FormControl>
<Button type="submit" colorScheme="primary" isLoading={loading}>
Envoyer ma demande
</Button>
</Stack>
</form>
</CardBody>
</Card>
)}
</Stack>
</Container>
)
}
+230
View File
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Spacer,
Spinner,
Stack,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
VStack,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type CodeBuySub } from '../../lib/api'
// Polling interval pour rafraîchir la liste
const POLL_MS = 10000
export function Codes() {
const toast = useToast()
const navigate = useNavigate()
const [codes, setCodes] = useState<CodeBuySub[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [username, setUsername] = useState('')
const [generatedCode, setGeneratedCode] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listCodes()
setCodes(res.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des codes impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
const createCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) {
toast({ status: 'error', title: 'Veuillez entrer un nom d\'utilisateur' })
return
}
setBusy('generate')
try {
const res = await api.createCode(username.trim())
setGeneratedCode(res.code)
setUsername('')
toast({ status: 'success', title: 'Code généré avec succès !' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Génération impossible', description: msg })
} finally {
setBusy(null)
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast({ status: 'success', title: 'Code copié dans le presse-papiers !' })
} catch (err) {
// Fallback pour les navigateurs qui bloquent clipboard
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed' // Évite les scrolls
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
toast({ status: 'success', title: 'Code copié dans le presse-papiers !' })
} else {
toast({ status: 'error', title: 'Impossible de copier. Essayez manuellement.' })
}
}
}
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Gestion des codes de souscription</Heading>
<Spacer />
</Flex>
{/* Formulaire de génération */}
<Box
mb={8}
p={6}
borderWidth="1px"
borderRadius="lg"
bg="bg-surface"
>
<form onSubmit={createCode}>
<VStack spacing={4} align="stretch">
<Stack direction={{ base: 'column', sm: 'row' }} align={{ base: 'stretch', sm: 'center' }} gap={4}>
<FormControl isRequired>
<FormLabel>Nom d\'utilisateur</FormLabel>
<Input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Entrez le nom d'utilisateur"
isDisabled={busy === 'generate'}
maxLength={64}
/>
</FormControl>
<Button
colorScheme="primary"
type="submit"
isLoading={busy === 'generate'}
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
>
Générer un code
</Button>
</Stack>
{generatedCode && (
<Box
p={4}
bg="gray.900"
borderRadius="md"
borderWidth="1px"
borderColor="whiteAlpha.200"
>
<Text fontSize="sm" color="gray.400" mb={2}>
Code généré pour <strong>{username}</strong> :
</Text>
<HStack>
<Text
fontFamily="mono"
fontSize="xl"
fontWeight="bold"
letterSpacing="widest"
>
{generatedCode}
</Text>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(generatedCode)}
>
Copier
</Button>
</HStack>
</Box>
)}
</VStack>
</form>
</Box>
{/* Liste des codes */}
{loading ? (
<Spinner />
) : codes.length === 0 ? (
<Text color="gray.500">Aucun code de souscription généré.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>ID</Th>
<Th>Utilisateur</Th>
<Th>Code</Th>
<Th>Date de création</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{codes.map((c) => (
<Tr key={c.id}>
<Td fontFamily="mono" fontSize="sm">{c.id.slice(0, 8)}...</Td>
<Td>
<Badge colorScheme="gray" px={2} py={1}>
{c.username}
</Badge>
</Td>
<Td fontFamily="mono" letterSpacing="wide">
{c.code_verif}
</Td>
<Td fontSize="sm" color="gray.400">
{new Date(c.created_at).toLocaleString('fr-FR')}
</Td>
<Td textAlign="right">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(c.code_verif)}
>
Copier
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import {
Badge,
Box,
Heading,
Spinner,
Stack,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
Alert,
AlertIcon,
} from '@chakra-ui/react'
import { api, ApiError, type Contact } from '../../lib/api'
export function Messages() {
const [messages, setMessages] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
api
.getMessage()
.then((res) => {
if (active) setMessages(res.messages)
})
.catch((err) => {
if (active) {
setError(err instanceof ApiError ? err.message : 'Erreur lors du chargement des messages.')
}
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
if (loading) {
return (
<Stack align="center" py={20}>
<Spinner />
</Stack>
)
}
if (error) {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
{error}
</Alert>
)
}
return (
<Stack spacing={6}>
<Stack spacing={1}>
<Heading size="lg">Messages de contact</Heading>
<Text color="gray.400">{messages.length} message(s) reçu(s)</Text>
</Stack>
{messages.length === 0 ? (
<Box bg="bg-surface" borderWidth="1px" borderColor="chakra-border-color" borderRadius="lg" p={8}>
<Text color="gray.400">Aucun message pour le moment.</Text>
</Box>
) : (
<Box
bg="bg-surface"
borderWidth="1px"
borderColor="chakra-border-color"
borderRadius="lg"
overflowX="auto"
>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Nom</Th>
<Th>Telegram</Th>
<Th>Sujet</Th>
<Th>Message</Th>
<Th>Reçu le</Th>
</Tr>
</Thead>
<Tbody>
{messages.map((m) => (
<Tr key={m.id}>
<Td fontWeight="medium">{m.username}</Td>
<Td>
<Badge colorScheme="blue">{m.telegram}</Badge>
</Td>
<Td>{m.sujet}</Td>
<Td maxW="320px" whiteSpace="normal">
{m.message}
</Td>
<Td whiteSpace="nowrap">
{new Date(m.created_at).toLocaleString('fr-FR')}
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
</Stack>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Button,
Flex,
Heading,
HStack,
Link,
Spacer,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
// Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
export function Demos() {
const toast = useToast()
const navigate = useNavigate()
const [demos, setDemos] = useState<Demo[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toDelete, setToDelete] = useState<Demo | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listDemos()
setDemos(res.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des démos impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
const create = async () => {
setBusy('new')
try {
await api.createDemo()
toast({ status: 'success', title: 'Démo lancée' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setBusy(null)
}
}
const extend = async (d: Demo) => {
setBusy(d.id)
try {
await api.extendDemo(d.id)
toast({ status: 'success', title: 'Démo prolongée de 30 jours' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Prolongation impossible', description: msg })
} finally {
setBusy(null)
}
}
const confirmDestroy = async () => {
if (!toDelete) return
const d = toDelete
setBusy(d.id)
try {
await api.deleteDemo(d.id)
toast({ status: 'success', title: 'Démo détruite' })
setToDelete(null)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Destruction impossible', description: msg })
} finally {
setBusy(null)
}
}
const isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed'
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Démos</Heading>
<Spacer />
<Button colorScheme="primary" isLoading={busy === 'new'} onClick={create}>
Nouvelle démo
</Button>
</Flex>
{loading ? (
<Spinner />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo active. Lancez-en une depuis un lead ou ci-dessus.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => (
<Tr key={d.id}>
<Td fontFamily="mono">{d.namespace}</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
{d.status === 'ready' ? (
<Link href={d.url} color="primary.500" isExternal>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
</Td>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
<HStack justify="flex-end">
<Button
size="sm"
variant="outline"
isDisabled={!isAlive(d.status) || busy === d.id}
onClick={() => extend(d)}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={() => setToDelete(d)}
>
Détruire
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
<ConfirmDialog
isOpen={!!toDelete}
title="Détruire la démo ?"
confirmLabel="Détruire"
isLoading={!!toDelete && busy === toDelete.id}
onConfirm={confirmDestroy}
onClose={() => setToDelete(null)}
>
La démo{' '}
<Text as="span" fontFamily="mono" fontWeight="semibold">
{toDelete?.namespace}
</Text>{' '}
et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible.
</ConfirmDialog>
</>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Button,
Flex,
Heading,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Lead } from '../../lib/api'
import { formatDate } from '../../lib/format'
import { useAuth } from '../../lib/auth'
export function Leads() {
const toast = useToast()
const navigate = useNavigate()
const { isAdmin } = useAuth()
const [leads, setLeads] = useState<Lead[]>([])
const [loading, setLoading] = useState(true)
const [launching, setLaunching] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listLeads()
setLeads(res.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des leads impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
}, [load])
const launchDemo = async (lead: Lead) => {
setLaunching(lead.id)
try {
await api.createDemo(lead.id)
toast({ status: 'success', title: 'Démo lancée', description: 'Provisioning en cours.' })
navigate('/app/demos')
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setLaunching(null)
}
}
if (loading) return <Spinner />
return (
<>
<Heading size="md" mb={6}>
Leads
</Heading>
{leads.length === 0 ? (
<Text color="gray.500">Aucun lead pour le moment.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Telegram</Th>
<Th>Statut</Th>
<Th>Reçu le</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{leads.map((l) => (
<Tr key={l.id}>
<Td fontWeight="medium">{l.company}</Td>
<Td>{l.email}</Td>
<Td>
<Badge>{l.status}</Badge>
</Td>
<Td>{formatDate(l.created_at)}</Td>
<Td textAlign="right">
{isAdmin && (
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="primary"
isLoading={launching === l.id}
onClick={() => launchDemo(l)}
>
Lancer une démo
</Button>
</Flex>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
Input,
Spacer,
Spinner,
Stack,
Text,
VStack,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../../lib/api'
export function Subscription() {
const toast = useToast()
const navigate = useNavigate()
const [typeAbo, setTypeAbo] = useState<string | null>(null)
const [expiredAt, setExpiredAt] = useState<Date | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [code, setCode] = useState('')
const [showRenewForm, setShowRenewForm] = useState(false)
const load = useCallback(async () => {
try {
const me = await api.me()
setTypeAbo(me.type_abonnement ?? null)
setExpiredAt(me.expired_at ? new Date(me.expired_at) : null)
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement de l\'abonnement impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
}, [load])
const submitCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code.trim()) {
toast({ status: 'error', title: 'Veuillez entrer un code' })
return
}
setBusy(true)
try {
await api.addCode(code.trim())
toast({ status: 'success', title: 'Abonnement premium activé !' })
setCode('')
setShowRenewForm(false)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Code invalide', description: msg })
} finally {
setBusy(false)
}
}
const isPremium = typeAbo === 'premium'
const showForm = !isPremium || showRenewForm
const daysRemaining = expiredAt
? Math.ceil((expiredAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Mon abonnement</Heading>
<Spacer />
</Flex>
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
{loading ? (
<Spinner />
) : (
<VStack align="stretch" spacing={4}>
<HStack flexWrap="wrap" rowGap={2}>
<Text color="gray.400">Statut actuel :</Text>
<Badge colorScheme={isPremium ? 'purple' : 'gray'} px={2} py={1}>
{isPremium ? 'Premium' : 'Demo'}
</Badge>
{isPremium && !showRenewForm && (
<Button
size="sm"
variant="link"
ml={2}
whiteSpace="normal"
textAlign="left"
onClick={() => setShowRenewForm(true)}
>
Renouveler avec un nouveau code
</Button>
)}
</HStack>
{isPremium && expiredAt && (
<Text fontSize="sm" color={daysRemaining !== null && daysRemaining <= 5 ? 'orange.400' : 'gray.400'}>
{daysRemaining !== null && daysRemaining > 0
? `Expire dans ${daysRemaining} jour${daysRemaining > 1 ? 's' : ''} (le ${expiredAt.toLocaleDateString('fr-FR')})`
: `Expiré depuis le ${expiredAt.toLocaleDateString('fr-FR')}`}
</Text>
)}
{showForm && (
<form onSubmit={submitCode}>
<Stack direction={{ base: 'column', sm: 'row' }} align={{ base: 'stretch', sm: 'center' }} gap={4}>
<FormControl isRequired>
<FormLabel>
{isPremium ? 'Nouveau code de renouvellement' : 'Code de souscription'}
</FormLabel>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="XXXX-XXXX-XXXX-XXXX"
isDisabled={busy}
fontFamily="mono"
letterSpacing="wide"
/>
</FormControl>
<HStack flexShrink={0}>
<Button
colorScheme="primary"
type="submit"
isLoading={busy}
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
>
{isPremium ? 'Renouveler' : 'Activer'}
</Button>
{isPremium && (
<Button
variant="ghost"
mt={{ base: 0, sm: 6 }}
h="40px"
flexShrink={0}
w={{ base: 'full', sm: 'auto' }}
onClick={() => {
setShowRenewForm(false)
setCode('')
}}
isDisabled={busy}
>
Annuler
</Button>
)}
</HStack>
</Stack>
</form>
)}
</VStack>
)}
</Box>
</>
)
}
+25
View File
@@ -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
+118
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/Contact.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/RequestDemo.test.tsx","./src/pages/RequestDemo.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Contact.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/Leads.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
},
})