- 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)
212 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|