espace-paie-odentas/components/PromoBanner.tsx
odentas 6170365fc0 feat: Ajout programme de parrainage, bannières promo, déduplication webhooks et avenants signés
- Programme de parrainage (referrals):
  * Page /parrainage pour clients et staff
  * API /api/referrals (GET, POST)
  * Table referrals avec tracking complet
  * Email template avec design orange/gradient
  * Réductions: 30€ HT parrain, 20€ HT filleul

- Bannières promotionnelles (promo_banners):
  * Page staff /staff/offres-promo pour gérer les bannières
  * API /api/promo-banners (CRUD complet)
  * Composant PromoBanner affiché en haut de l'espace
  * Compte à rebours optionnel
  * Customisation couleurs (gradient, texte, CTA)

- Déduplication des webhooks DocuSeal:
  * Table webhook_events pour tracker les webhooks traités
  * Helper checkAndMarkWebhookProcessed()
  * Intégré dans docuseal-amendment et docuseal-amendment-completed
  * Prévient les doublons d'emails

- Avenants signés:
  * API GET /api/contrats/[id]/avenants
  * Affichage des avenants signés dans DocumentsCard
  * Génération d'URLs presignées S3

- Brouillons d'emails groupés:
  * Table bulk_email_drafts pour sauvegarder les brouillons
  * Template HTML bulk-email-template.html

- Améliorations ContractsGrid:
  * Ajout filtre par production (dépendant de la structure)
  * Tri par production

- Templates emails:
  * referral-template.html (parrainage)
  * bulk-email-template.html (emails groupés staff)
2025-10-31 23:31:53 +01:00

212 lines
6.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { X, ChevronRight } from "lucide-react";
interface PromoBanner {
id: string;
text: string;
cta_text?: string | null;
cta_link?: string | null;
countdown_enabled: boolean;
countdown_end_date?: string | null;
gradient_from: string;
gradient_to: string;
text_color: string;
cta_bg_color: string;
cta_text_color: string;
is_active: boolean;
}
function useCountdown(endDate: string | null | undefined) {
const [timeLeft, setTimeLeft] = useState<string>("");
useEffect(() => {
if (!endDate) return;
const calculateTimeLeft = () => {
const now = new Date().getTime();
const end = new Date(endDate).getTime();
const diff = end - now;
if (diff <= 0) {
setTimeLeft("Terminé");
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
if (days > 0) {
setTimeLeft(`${days}j ${hours}h ${minutes}m ${seconds}s`);
} else if (hours > 0) {
setTimeLeft(`${hours}h ${minutes}m ${seconds}s`);
} else if (minutes > 0) {
setTimeLeft(`${minutes}m ${seconds}s`);
} else {
setTimeLeft(`${seconds}s`);
}
};
calculateTimeLeft();
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [endDate]);
return timeLeft;
}
export function PromoBanner() {
const [banner, setBanner] = useState<PromoBanner | null>(null);
const [dismissed, setDismissed] = useState(false);
const [loading, setLoading] = useState(true);
const timeLeft = useCountdown(banner?.countdown_end_date);
useEffect(() => {
async function fetchBanner() {
try {
const res = await fetch("/api/promo-banners", {
credentials: "include",
cache: "no-store"
});
if (!res.ok) throw new Error("Erreur fetch");
const data = await res.json();
if (data.banner) {
setBanner(data.banner);
// Vérifier si la bannière a été fermée dans ce session
const dismissedId = sessionStorage.getItem("promo_banner_dismissed");
if (dismissedId === data.banner.id) {
setDismissed(true);
}
}
} catch (err) {
console.error("Erreur lors du chargement de la bannière promo:", err);
} finally {
setLoading(false);
}
}
fetchBanner();
}, []);
const handleDismiss = () => {
if (banner) {
sessionStorage.setItem("promo_banner_dismissed", banner.id);
}
setDismissed(true);
};
if (loading || !banner || dismissed || !banner.is_active) {
return null;
}
// Convertir les classes Tailwind en couleurs CSS
const getGradientColors = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-100': '#f1f5f9',
'slate-200': '#e2e8f0',
'indigo-100': '#e0e7ff',
'purple-100': '#f3e8ff',
'blue-50': '#eff6ff',
'cyan-100': '#cffafe',
'purple-50': '#faf5ff',
'pink-100': '#fce7f3',
'orange-50': '#fff7ed',
'amber-100': '#fef3c7',
'green-50': '#f0fdf4',
'teal-100': '#ccfbf1',
};
return colorMap[tailwindClass] || '#f1f5f9';
};
const getTextColor = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-800': '#1e293b',
'slate-900': '#0f172a',
'white': '#ffffff',
'indigo-900': '#312e81',
'purple-900': '#581c87',
};
return colorMap[tailwindClass] || '#1e293b';
};
const getCtaTextColor = (tailwindClass: string): string => {
const colorMap: Record<string, string> = {
'slate-800': '#1e293b',
'slate-900': '#0f172a',
'white': '#ffffff',
};
return colorMap[tailwindClass] || '#0f172a';
};
const gradientFrom = getGradientColors(banner.gradient_from);
const gradientTo = getGradientColors(banner.gradient_to);
const textColor = getTextColor(banner.text_color || 'slate-800');
const ctaBgColor = banner.cta_bg_color || '#efc543';
const ctaTextColor = getCtaTextColor(banner.cta_text_color || 'slate-900');
return (
<div
className="py-3 px-4 relative"
style={{
background: `linear-gradient(to right, ${gradientFrom}, ${gradientTo})`,
color: textColor
}}
role="banner"
aria-label="Bannière promotionnelle"
>
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
{/* Texte principal */}
<div className="flex-1 flex items-center gap-4 flex-wrap">
<p className="text-sm font-medium">{banner.text}</p>
{/* Compte à rebours */}
{banner.countdown_enabled && banner.countdown_end_date && timeLeft && (
<div
className="inline-flex items-center gap-2 backdrop-blur-sm rounded-lg px-3 py-1"
style={{
backgroundColor: banner.text_color === 'white' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.05)'
}}
>
<span className="text-xs font-semibold">{timeLeft}</span>
</div>
)}
</div>
{/* CTA */}
{banner.cta_text && banner.cta_link && (
<a
href={banner.cta_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition px-4 py-2 rounded-lg text-sm font-semibold whitespace-nowrap hover:opacity-90"
style={{
backgroundColor: ctaBgColor,
color: ctaTextColor
}}
>
{banner.cta_text}
<ChevronRight className="w-4 h-4" />
</a>
)}
{/* Bouton fermer */}
<button
onClick={handleDismiss}
className="rounded-full p-1 transition hover:opacity-70"
style={{ color: textColor }}
aria-label="Fermer la bannière"
title="Fermer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
);
}