@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4513
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user