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
+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