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.GET("/profile/alerts", d.ProfileH.GetAlerts)
|
||||||
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
|
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
|
||||||
admin.POST("/profile/alerts/test", d.ProfileH.TestAlerts)
|
admin.POST("/profile/alerts/test", d.ProfileH.TestAlerts)
|
||||||
|
admin.GET("/premium", d.SubH.GetPremiumUser)
|
||||||
if d.DemosH != nil {
|
if d.DemosH != nil {
|
||||||
admin.POST("/demos", d.DemosH.Create)
|
admin.POST("/demos", d.DemosH.Create)
|
||||||
admin.GET("/demos", d.DemosH.List)
|
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"})
|
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.
|
// TableName définit explicitement le nom de la table.
|
||||||
func (CodeBuySub) TableName() string { return "code_buy_subs" }
|
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"
|
"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.
|
// Store définit les opérations de persistance pour les codes de souscription.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
// CreateCodeForSub crée un nouveau code pour un utilisateur.
|
// CreateCodeForSub crée un nouveau code pour un utilisateur.
|
||||||
@@ -19,6 +24,8 @@ type Store interface {
|
|||||||
// ListCodes liste tous les codes.
|
// ListCodes liste tous les codes.
|
||||||
ListCodes() ([]CodeBuySub, error)
|
ListCodes() ([]CodeBuySub, error)
|
||||||
AddCodeForSub(username, code string) (auth.User, 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.
|
// 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.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{}{
|
if err := s.db.Model(&u).Updates(map[string]interface{}{
|
||||||
"type_abo": u.TypeAbo,
|
"type_abo": u.TypeAbo,
|
||||||
"expired_at": u.ExpiredAt,
|
"expired_at": u.ExpiredAt,
|
||||||
@@ -106,6 +113,25 @@ func (s *GormStore) ListCodes() ([]CodeBuySub, error) {
|
|||||||
return codes, nil
|
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).
|
// MemStore implémente Store en mémoire (pour dev/tests).
|
||||||
type MemStore struct {
|
type MemStore struct {
|
||||||
mu sync.RWMutex
|
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.
|
// il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous.
|
||||||
return auth.User{Username: username, TypeAbo: "premium"}, nil
|
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
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Les deux seuls types d'alerte que peut envoyer un livreur (voir
|
||||||
|
// DeliveryAlertsSection) : contrôle de police / guet-apens.
|
||||||
const INITIAL: Alert[] = [
|
const INITIAL: Alert[] = [
|
||||||
{ id: '1', driver: 'lucas_d', message: 'Accès bloqué, portail fermé', time: '03/08/2026, 14:32', active: true },
|
{ id: '1', driver: 'lucas_d', message: 'Guet-apens', 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: '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 +
|
// Le bouton "Résoudre" change réellement l'état de l'alerte (badge +
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export interface CodeBuySub {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PremiumUser {
|
||||||
|
username: string
|
||||||
|
activated_at: string
|
||||||
|
expired_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -223,6 +229,7 @@ export const api = {
|
|||||||
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
|
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
|
||||||
|
|
||||||
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
||||||
|
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
|
||||||
createCode: (username: string) =>
|
createCode: (username: string) =>
|
||||||
request<{ code: string }>('POST', '/codes', { username }),
|
request<{ code: string }>('POST', '/codes', { username }),
|
||||||
addCode: (code: string) =>
|
addCode: (code: string) =>
|
||||||
|
|||||||
@@ -262,8 +262,8 @@ export function Documentation() {
|
|||||||
|
|
||||||
{/* Sélecteur de rôle + sommaire */}
|
{/* Sélecteur de rôle + sommaire */}
|
||||||
<Box borderTopWidth="1px" borderBottomWidth="1px" bg="chakra-subtle-bg" position="sticky" top={0} zIndex={2}>
|
<Box borderTopWidth="1px" borderBottomWidth="1px" bg="chakra-subtle-bg" position="sticky" top={0} zIndex={2}>
|
||||||
<Container maxW="container.lg" py={3}>
|
<Container maxW="container.lg" py={{ base: 2, md: 3 }}>
|
||||||
<HStack spacing={2} mb={2} overflowX="auto">
|
<HStack spacing={2} mb={{ base: 0, md: 2 }} overflowX="auto">
|
||||||
{ROLES.map((r) => (
|
{ROLES.map((r) => (
|
||||||
<Button
|
<Button
|
||||||
key={r.key}
|
key={r.key}
|
||||||
@@ -278,10 +278,14 @@ export function Documentation() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</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) => (
|
{activeRole.features.map((f) => (
|
||||||
<WrapItem key={f.id}>
|
<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}
|
{f.kicker}
|
||||||
</Text>
|
</Text>
|
||||||
</WrapItem>
|
</WrapItem>
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const plans: Plan[] = [
|
|||||||
description: 'Passez en production : stockage persistant, votre démo n\'expire plus.',
|
description: 'Passez en production : stockage persistant, votre démo n\'expire plus.',
|
||||||
features: [
|
features: [
|
||||||
'Tout ce qui est inclus dans Démo',
|
'Tout ce qui est inclus dans Démo',
|
||||||
'Votre nom de domaine',
|
|
||||||
'Support prioritaire',
|
'Support prioritaire',
|
||||||
],
|
],
|
||||||
cta: 'Nous contacter',
|
cta: 'Nous contacter',
|
||||||
|
|||||||
@@ -24,15 +24,32 @@ import {
|
|||||||
VStack,
|
VStack,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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
|
// Polling interval pour rafraîchir la liste
|
||||||
const POLL_MS = 10000
|
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() {
|
export function Codes() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [codes, setCodes] = useState<CodeBuySub[]>([])
|
const [codes, setCodes] = useState<CodeBuySub[]>([])
|
||||||
|
const [premiumUsers, setPremiumUsers] = useState<PremiumUser[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
@@ -40,8 +57,9 @@ export function Codes() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.listCodes()
|
const [codesRes, premiumRes] = await Promise.all([api.listCodes(), api.listPremiumUsers()])
|
||||||
setCodes(res.items ?? [])
|
setCodes(codesRes.items ?? [])
|
||||||
|
setPremiumUsers(premiumRes.items ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
|
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
|
||||||
else toast({ status: 'error', title: 'Chargement des codes impossible' })
|
else toast({ status: 'error', title: 'Chargement des codes impossible' })
|
||||||
@@ -179,6 +197,9 @@ export function Codes() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Liste des codes */}
|
{/* Liste des codes */}
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Codes générés (non encore utilisés)
|
||||||
|
</Heading>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : codes.length === 0 ? (
|
) : codes.length === 0 ? (
|
||||||
@@ -225,6 +246,60 @@ export function Codes() {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</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