chore: build
ci-web / test (push) Canceled after 0s
ci-api / test (push) Canceled after 24s

This commit is contained in:
Xor290
2026-08-22 21:58:39 +02:00
parent f407bc3dd0
commit 0f28d4757e
9 changed files with 164 additions and 11 deletions
@@ -77,6 +77,7 @@ func New(d Deps) *gin.Engine {
admin.GET("/profile/alerts", d.ProfileH.GetAlerts)
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
admin.POST("/profile/alerts/test", d.ProfileH.TestAlerts)
admin.GET("/premium", d.SubH.GetPremiumUser)
if d.DemosH != nil {
admin.POST("/demos", d.DemosH.Create)
admin.GET("/demos", d.DemosH.List)
+21
View File
@@ -137,3 +137,24 @@ func (h *Handler) AddCode(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"})
}
// GetPremiumUser : GET /premium — liste des clients premium (nom, date de
// saisie du code, date de fin d'abonnement) pour l'admin.
func (h *Handler) GetPremiumUser(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
if p.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "vous n'avez pas les droits"})
return
}
items, err := h.store.GetUserPremium()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
+11
View File
@@ -15,3 +15,14 @@ type CodeBuySub struct {
// TableName définit explicitement le nom de la table.
func (CodeBuySub) TableName() string { return "code_buy_subs" }
// PremiumUser : client actuellement en abonnement premium, tel qu'affiché
// à l'admin sur la page des codes. ActivatedAt (date de saisie du code)
// n'est pas stockée telle quelle — le code est supprimé une fois consommé
// (voir Store.AddCodeForSub) — mais l'abonnement dure toujours exactement
// PremiumDuration, donc on la déduit de ExpiredAt (voir GormStore).
type PremiumUser struct {
Username string `json:"username"`
ActivatedAt time.Time `json:"activated_at"`
ExpiredAt time.Time `json:"expired_at"`
}
+34 -1
View File
@@ -10,6 +10,11 @@ import (
"gorm.io/gorm"
)
// PremiumDuration : durée d'un abonnement premium débloqué par un code
// (30 jours). Sert à la fois à fixer ExpiredAt lors de l'activation et à
// retrouver la date de saisie du code (ExpiredAt - PremiumDuration).
const PremiumDuration = 30 * 24 * time.Hour
// Store définit les opérations de persistance pour les codes de souscription.
type Store interface {
// CreateCodeForSub crée un nouveau code pour un utilisateur.
@@ -19,6 +24,8 @@ type Store interface {
// ListCodes liste tous les codes.
ListCodes() ([]CodeBuySub, error)
AddCodeForSub(username, code string) (auth.User, error)
// GetUserPremium liste les clients actuellement en abonnement premium.
GetUserPremium() ([]PremiumUser, error)
}
// GormStore implémente Store avec PostgreSQL via GORM.
@@ -78,7 +85,7 @@ func (s *GormStore) AddCodeForSub(username, code string) (auth.User, error) {
}
u.TypeAbo = "premium"
u.ExpiredAt = time.Now().UTC().AddDate(0, 0, 30)
u.ExpiredAt = time.Now().UTC().Add(PremiumDuration)
if err := s.db.Model(&u).Updates(map[string]interface{}{
"type_abo": u.TypeAbo,
"expired_at": u.ExpiredAt,
@@ -106,6 +113,25 @@ func (s *GormStore) ListCodes() ([]CodeBuySub, error) {
return codes, nil
}
// GetUserPremium liste les clients dont l'abonnement est premium, avec la
// date de saisie du code (déduite : ExpiredAt - PremiumDuration) et la date
// de fin d'abonnement (ExpiredAt). Trié par échéance la plus proche.
func (s *GormStore) GetUserPremium() ([]PremiumUser, error) {
var users []auth.User
if err := s.db.Where("type_abo = ?", "premium").Order("expired_at asc").Find(&users).Error; err != nil {
return nil, err
}
out := make([]PremiumUser, 0, len(users))
for _, u := range users {
out = append(out, PremiumUser{
Username: u.Username,
ActivatedAt: u.ExpiredAt.Add(-PremiumDuration),
ExpiredAt: u.ExpiredAt,
})
}
return out, nil
}
// MemStore implémente Store en mémoire (pour dev/tests).
type MemStore struct {
mu sync.RWMutex
@@ -174,3 +200,10 @@ func (m *MemStore) AddCodeForSub(username, code string) (auth.User, error) {
// il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous.
return auth.User{Username: username, TypeAbo: "premium"}, nil
}
// GetUserPremium : le MemStore (dev/tests) ne connaît pas la table des
// utilisateurs (voir AddCodeForSub) — il ne peut donc pas lister les clients
// premium. Renvoie une liste vide plutôt qu'une erreur.
func (m *MemStore) GetUserPremium() ([]PremiumUser, error) {
return []PremiumUser{}, nil
}
@@ -11,9 +11,11 @@ interface Alert {
active: boolean
}
// Les deux seuls types d'alerte que peut envoyer un livreur (voir
// DeliveryAlertsSection) : contrôle de police / guet-apens.
const INITIAL: Alert[] = [
{ id: '1', driver: 'lucas_d', message: 'Accès bloqué, portail fermé', time: '03/08/2026, 14:32', active: true },
{ id: '2', driver: 'emma_l', message: 'Client injoignable', time: '02/08/2026, 19:05', active: false },
{ id: '1', driver: 'lucas_d', message: 'Guet-apens', time: '03/08/2026, 14:32', active: true },
{ id: '2', driver: 'emma_l', message: 'Contrôle de police', time: '02/08/2026, 19:05', active: false },
]
// Le bouton "Résoudre" change réellement l'état de l'alerte (badge +
+7
View File
@@ -97,6 +97,12 @@ export interface CodeBuySub {
created_at: string
}
export interface PremiumUser {
username: string
activated_at: string
expired_at: string
}
export interface Contact {
id: string
username: string
@@ -223,6 +229,7 @@ export const api = {
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
createCode: (username: string) =>
request<{ code: string }>('POST', '/codes', { username }),
addCode: (code: string) =>
+8 -4
View File
@@ -262,8 +262,8 @@ export function Documentation() {
{/* Sélecteur de rôle + sommaire */}
<Box borderTopWidth="1px" borderBottomWidth="1px" bg="chakra-subtle-bg" position="sticky" top={0} zIndex={2}>
<Container maxW="container.lg" py={3}>
<HStack spacing={2} mb={2} overflowX="auto">
<Container maxW="container.lg" py={{ base: 2, md: 3 }}>
<HStack spacing={2} mb={{ base: 0, md: 2 }} overflowX="auto">
{ROLES.map((r) => (
<Button
key={r.key}
@@ -278,10 +278,14 @@ export function Documentation() {
</Button>
))}
</HStack>
<Wrap spacing={5} shouldWrapChildren>
{/* Sommaire des sections : utile en desktop (nav rapide), mais sur
mobile ça se réplique sur plusieurs lignes et reste collé en
haut de l'écran en permanence (sticky) masqué plutôt que de
manger l'espace, le scroll normal suffit sur ce format. */}
<Wrap spacing={4} shouldWrapChildren display={{ base: 'none', md: 'flex' }}>
{activeRole.features.map((f) => (
<WrapItem key={f.id}>
<Text as="a" href={`#${f.id}`} fontSize="sm" fontWeight="semibold" color="primary.500" whiteSpace="nowrap" _hover={{ textDecoration: 'underline' }}>
<Text as="a" href={`#${f.id}`} fontSize="xs" fontWeight="medium" color="gray.500" whiteSpace="nowrap" _hover={{ color: 'primary.500', textDecoration: 'underline' }}>
{f.kicker}
</Text>
</WrapItem>
-1
View File
@@ -53,7 +53,6 @@ const plans: Plan[] = [
description: 'Passez en production : stockage persistant, votre démo n\'expire plus.',
features: [
'Tout ce qui est inclus dans Démo',
'Votre nom de domaine',
'Support prioritaire',
],
cta: 'Nous contacter',
+78 -3
View File
@@ -24,15 +24,32 @@ import {
VStack,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type CodeBuySub } from '../../lib/api'
import { api, ApiError, type CodeBuySub, type PremiumUser } from '../../lib/api'
// Polling interval pour rafraîchir la liste
const POLL_MS = 10000
// Formatage date FR court (ex. "22 août 2026"). Renvoie "—" si vide/invalide.
function formatDate(iso: string) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
}
// Nombre de jours restants avant l'échéance (négatif = déjà expiré).
function daysLeft(iso: string): number | null {
if (!iso) return null
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return null
return Math.ceil((d.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
}
export function Codes() {
const toast = useToast()
const navigate = useNavigate()
const [codes, setCodes] = useState<CodeBuySub[]>([])
const [premiumUsers, setPremiumUsers] = useState<PremiumUser[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [username, setUsername] = useState('')
@@ -40,8 +57,9 @@ export function Codes() {
const load = useCallback(async () => {
try {
const res = await api.listCodes()
setCodes(res.items ?? [])
const [codesRes, premiumRes] = await Promise.all([api.listCodes(), api.listPremiumUsers()])
setCodes(codesRes.items ?? [])
setPremiumUsers(premiumRes.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des codes impossible' })
@@ -179,6 +197,9 @@ export function Codes() {
</Box>
{/* Liste des codes */}
<Heading size="sm" mb={3}>
Codes générés (non encore utilisés)
</Heading>
{loading ? (
<Spinner />
) : codes.length === 0 ? (
@@ -225,6 +246,60 @@ export function Codes() {
</Table>
</TableContainer>
)}
{/* Clients premium (codes déjà activés) */}
<Heading size="sm" mt={10} mb={3}>
Clients premium
</Heading>
{loading ? (
<Spinner />
) : premiumUsers.length === 0 ? (
<Text color="gray.500">Aucun client premium pour le moment.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Client</Th>
<Th>Code activé le</Th>
<Th>Abonnement expire le</Th>
<Th>Statut</Th>
</Tr>
</Thead>
<Tbody>
{premiumUsers.map((u) => {
const left = daysLeft(u.expired_at)
return (
<Tr key={u.username}>
<Td>
<Badge colorScheme="purple" px={2} py={1}>
{u.username}
</Badge>
</Td>
<Td fontSize="sm" color="gray.400">
{formatDate(u.activated_at)}
</Td>
<Td fontSize="sm" color="gray.400">
{formatDate(u.expired_at)}
</Td>
<Td>
{left === null ? (
<Text color="gray.400">—</Text>
) : left < 0 ? (
<Badge colorScheme="red">Expiré</Badge>
) : (
<Badge colorScheme={left <= 7 ? 'orange' : 'green'}>
{left} j restant{left > 1 ? 's' : ''}
</Badge>
)}
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
)}
</>
)
}