chore: build
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user