chore: update
ci-web / test (push) Successful in 14m40s

This commit is contained in:
Xor290
2026-08-03 11:23:42 +02:00
parent 2592ea1c6f
commit e2802aeb59
5 changed files with 440 additions and 241 deletions
+137
View File
@@ -0,0 +1,137 @@
import type { ReactNode } from 'react'
import {
Badge,
Box,
Collapse,
HStack,
Icon,
IconButton,
Link,
Spinner,
Stack,
Text,
} from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons'
import type { Demo, DemoDetails } from '../lib/api'
import { statusColor, statusLabel } from '../lib/format'
import { ChevronIcon, PodStatusPanel } from './PodStatusPanel'
interface DemoCardProps {
demo: Demo
isOpen: boolean
onToggle: () => void
detailsLoading: boolean
details: DemoDetails | null
onEditDomain: () => void
/** Affiché sous le badge de statut si fourni (ex. "Expire dans 12 jours"). */
expiresLabel?: string
/** Boutons d'action (extend/détruire...) affichés en bas de carte. */
actions?: ReactNode
}
// DemoCard : équivalent "carte" d'une ligne de tableau démo, pour mobile —
// évite le scroll horizontal du tableau. Tap sur la carte (hors liens/
// boutons) = déplie l'état live des pods, identique au clic de ligne en
// desktop.
export function DemoCard({
demo,
isOpen,
onToggle,
detailsLoading,
details,
onEditDomain,
expiresLabel,
actions,
}: DemoCardProps) {
return (
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg-surface">
<Box p={4} cursor="pointer" onClick={onToggle} _active={{ bg: 'chakra-subtle-bg' }}>
<HStack justify="space-between" align="start">
<HStack spacing={2} minW={0}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
flexShrink={0}
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text fontFamily="mono" fontSize="sm" noOfLines={1} wordBreak="break-all">
{demo.namespace}
</Text>
</HStack>
<Badge colorScheme={statusColor(demo.status)} flexShrink={0}>
{statusLabel(demo.status)}
</Badge>
</HStack>
<Stack spacing={1} mt={3} fontSize="sm">
<HStack justify="space-between">
<Text color="gray.500">Client</Text>
<Text>{demo.username || '—'}</Text>
</HStack>
<HStack justify="space-between" align="start">
<Text color="gray.500" flexShrink={0}>
URL
</Text>
<HStack spacing={1} minW={0}>
{demo.status === 'ready' ? (
<Link
href={demo.url}
color="primary.500"
isExternal
noOfLines={1}
wordBreak="break-all"
onClick={(e) => e.stopPropagation()}
>
{demo.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
flexShrink={0}
onClick={(e) => {
e.stopPropagation()
onEditDomain()
}}
/>
</HStack>
</HStack>
{expiresLabel && (
<HStack justify="space-between">
<Text color="gray.500">Expire dans</Text>
<Text>{expiresLabel}</Text>
</HStack>
)}
</Stack>
{actions && (
<HStack mt={3} spacing={2} onClick={(e) => e.stopPropagation()}>
{actions}
</HStack>
)}
</Box>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<HStack justify="center" py={2}>
<Spinner size="sm" />
</HStack>
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Box>
)
}
+51 -35
View File
@@ -14,6 +14,8 @@ import {
} from '@chakra-ui/react'
import { Link as RouterLink } from 'react-router-dom'
const SUPPORT_TELEGRAM_URL = 'https://t.me/OMNEX_CORP'
interface Plan {
name: string
price: string
@@ -21,9 +23,14 @@ interface Plan {
description: string
features: string[]
cta: string
ctaHref: string
ctaExternal?: boolean
highlighted?: boolean
}
// Seuls 2 types d'abonnement existent réellement dans le système : Démo
// (essai gratuit 30 jours) et Premium (stockage persistant, n'expire plus —
// voir demos.Status/TypeAbo côté backend).
const plans: Plan[] = [
{
name: 'Démo',
@@ -33,41 +40,27 @@ const plans: Plan[] = [
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: 'Créer un compte',
ctaHref: '/register',
},
{
name: 'Pro',
name: 'Premium',
price: 'Sur devis',
period: 'par mois',
description: 'Pour déployer la plateforme en production sur votre activité.',
period: '',
description: 'Passez en production : stockage persistant, votre démo n\'expire plus.',
features: [
'Tout ce qui est inclus dans Démo',
'Déploiement production dédié',
'WAF, TLS, sauvegardes',
'GPS, paiements et notifications',
'Stockage persistant, n\'expire plus',
'Support prioritaire',
],
cta: 'Nous contacter',
ctaHref: SUPPORT_TELEGRAM_URL,
ctaExternal: true,
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() {
@@ -76,18 +69,19 @@ export function Pricing() {
<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.
Commencez par une démo gratuite de 30 jours, puis passez en premium quand vous êtes prêt.
</Text>
</Stack>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8} alignItems="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8} alignItems="stretch" maxW="2xl" mx="auto">
{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="/register">Créez un compte</RouterLinkText> — un
Besoin d'un devis précis ?{' '}
<ExternalLinkText href={SUPPORT_TELEGRAM_URL}>Contactez-nous</ExternalLinkText> — un
commercial vous recontacte.
</Text>
</Container>
@@ -144,22 +138,44 @@ function PlanCard({ plan }: { plan: Plan }) {
))}
</List>
<Button
as={RouterLink}
to="/register"
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
>
{plan.cta}
</Button>
{plan.ctaExternal ? (
<Button
as="a"
href={plan.ctaHref}
target="_blank"
rel="noopener noreferrer"
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
>
{plan.cta}
</Button>
) : (
<Button
as={RouterLink}
to={plan.ctaHref}
colorScheme="primary"
variant={plan.highlighted ? 'solid' : 'outline'}
size="lg"
>
{plan.cta}
</Button>
)}
</Stack>
)
}
function RouterLinkText({ to, children }: { to: string; children: React.ReactNode }) {
function ExternalLinkText({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Box as={RouterLink} to={to} color="primary.500" fontWeight="medium" display="inline">
<Box
as="a"
href={href}
target="_blank"
rel="noopener noreferrer"
color="primary.500"
fontWeight="medium"
display="inline"
>
{children}
</Box>
)
+146 -120
View File
@@ -12,6 +12,7 @@ import {
Link,
Spacer,
Spinner,
Stack,
Table,
TableContainer,
Tbody,
@@ -29,6 +30,7 @@ import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateDemoModal } from '../../components/CreateDemoModal'
import { EditDomainModal } from '../../components/EditDomainModal'
import { DemoCard } from '../../components/DemoCard'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un provisioning en cours => on rafraîchit régulièrement.
@@ -144,6 +146,34 @@ export function Demos() {
const isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed'
const renderActions = (d: Demo) => (
<>
<Button
size="sm"
variant="outline"
isDisabled={!isAlive(d.status) || busy === d.id}
onClick={(e) => {
e.stopPropagation()
extend(d)
}}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={(e) => {
e.stopPropagation()
setToDelete(d)
}}
>
Détruire
</Button>
</>
)
return (
<>
<Flex mb={6} align="center" gap={4} wrap="wrap">
@@ -167,131 +197,127 @@ export function Demos() {
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo active. Lancez-en une avec le bouton ci-dessus.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text>{d.namespace}</Text>
</HStack>
</Td>
<Td>
{d.username ? (
<Text>{d.username}</Text>
) : (
<Text color="gray.400"></Text>
)}
</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
<>
{/* Desktop/tablette : tableau. */}
<TableContainer borderWidth="1px" borderRadius="lg" display={{ base: 'none', md: 'block' }}>
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text>{d.namespace}</Text>
</HStack>
</Td>
<Td>
{d.username ? (
<Text>{d.username}</Text>
) : (
<Text color="gray.400"></Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</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={(e) => {
e.stopPropagation()
extend(d)
}}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={(e) => {
e.stopPropagation()
setToDelete(d)
}}
>
Détruire
</Button>
</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={6}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : details ? (
<PodStatusPanel state={details.state} />
</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
<Text color="gray.400"></Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>
</Table>
</TableContainer>
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
<HStack justify="flex-end">{renderActions(d)}</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={6}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>
</Table>
</TableContainer>
{/* Mobile : cartes (pas de scroll horizontal). */}
<Stack spacing={3} display={{ base: 'flex', md: 'none' }}>
{demos.map((d) => (
<DemoCard
key={d.id}
demo={d}
isOpen={expandedId === d.id}
onToggle={() => toggleRow(d)}
detailsLoading={detailsLoading}
details={expandedId === d.id ? details : null}
onEditDomain={() => setEditingDomain(d)}
expiresLabel={isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}
actions={renderActions(d)}
/>
))}
</Stack>
</>
)}
<ConfirmDialog
+105 -85
View File
@@ -12,6 +12,7 @@ import {
Link,
Spacer,
Spinner,
Stack,
Table,
TableContainer,
Tbody,
@@ -29,6 +30,7 @@ import { statusColor, statusLabel } from '../../lib/format'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
import { CreateDemoModal } from '../../components/CreateDemoModal'
import { EditDomainModal } from '../../components/EditDomainModal'
import { DemoCard } from '../../components/DemoCard'
// Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
@@ -131,93 +133,111 @@ export function PremiumDemos() {
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo premium pour le moment.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text>{d.namespace}</Text>
</HStack>
</Td>
<Td>{d.username || <Text color="gray.400">—</Text>}</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400">—</Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={4}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Spinner size="sm" />
) : details ? (
<PodStatusPanel state={details.state} />
<>
{/* Desktop/tablette : tableau. */}
<TableContainer borderWidth="1px" borderRadius="lg" display={{ base: 'none', md: 'block' }}>
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text>{d.namespace}</Text>
</HStack>
</Td>
<Td>{d.username || <Text color="gray.400">—</Text>}</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
<Text color="gray.400">—</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>
</Table>
</TableContainer>
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={4}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Spinner size="sm" />
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>
</Table>
</TableContainer>
{/* Mobile : cartes (pas de scroll horizontal). */}
<Stack spacing={3} display={{ base: 'flex', md: 'none' }}>
{demos.map((d) => (
<DemoCard
key={d.id}
demo={d}
isOpen={expandedId === d.id}
onToggle={() => toggleRow(d)}
detailsLoading={detailsLoading}
details={expandedId === d.id ? details : null}
onEditDomain={() => setEditingDomain(d)}
/>
))}
</Stack>
</>
)}
<EditDomainModal
+1 -1
View File
@@ -1 +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/CreateDemoModal.tsx","./src/components/EditDomainModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PasswordInput.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/AdminLogin.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
{"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/CreateDemoModal.tsx","./src/components/DemoCard.tsx","./src/components/EditDomainModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PasswordInput.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/AdminLogin.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}