feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s
Some checks failed
Build and Push App / build (push) Failing after 16m2s
This commit is contained in:
@@ -31,12 +31,24 @@ export default function AnalyticsTracker() {
|
||||
|
||||
// 1. Track Page Views
|
||||
useEffect(() => {
|
||||
// Attempt to get userId from localStorage (common in this app's auth setup)
|
||||
let userId = null;
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
userId = user.id;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
sendEvent({
|
||||
type: 'PAGE_VIEW',
|
||||
path: pathname,
|
||||
metadata: {
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${window.innerWidth}x${window.innerHeight}`,
|
||||
referrer: document.referrer,
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
const url = encodeURIComponent(`${window.location.origin}/annuaire/${business.id}`);
|
||||
const text = encodeURIComponent(`Découvrez ${business.name} sur Afropreunariat`);
|
||||
const text = encodeURIComponent(`Découvrez ${business.name} sur Afrohub`);
|
||||
|
||||
let shareLink = '';
|
||||
switch(platform) {
|
||||
|
||||
@@ -37,7 +37,7 @@ const CookieBanner = () => {
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">Nous respectons votre vie privée</h3>
|
||||
<p className="text-sm text-gray-400 leading-relaxed">
|
||||
Afropreunariat utilise des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu.
|
||||
Afrohub utilise des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu.
|
||||
Certains sont essentiels, d'autres nous aident à grandir avec vous.
|
||||
Consultez nos <Link href="/cgu" className="text-brand-500 hover:underline">CGU</Link> pour en savoir plus.
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Facebook, Twitter, Instagram, Linkedin } from 'lucide-react';
|
||||
|
||||
interface FooterProps {
|
||||
settings?: any;
|
||||
@@ -14,23 +15,52 @@ const Footer = ({ settings }: FooterProps) => {
|
||||
if (pathname.startsWith('/dashboard')) return null;
|
||||
|
||||
const siteInfo = settings || {
|
||||
siteName: "Afropreunariat",
|
||||
siteName: "Afrohub",
|
||||
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain. Visibilité, connexion et croissance pour les TPE/PME.",
|
||||
contactEmail: "support@afropreunariat.com",
|
||||
contactPhone: "+225 00 00 00 00 00",
|
||||
contactEmail: "support@afrohub.com",
|
||||
address: "Abidjan, Côte d'Ivoire / Paris, France",
|
||||
footerText: "© 2025 Afropreunariat. Tous droits réservés."
|
||||
footerText: "© 2025 Afrohub. Tous droits réservés.",
|
||||
facebookUrl: "#",
|
||||
twitterUrl: "#",
|
||||
instagramUrl: "#",
|
||||
linkedinUrl: "#"
|
||||
};
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Facebook, url: siteInfo.facebookUrl, label: "Facebook" },
|
||||
{ icon: Twitter, url: siteInfo.twitterUrl, label: "Twitter" },
|
||||
{ icon: Instagram, url: siteInfo.instagramUrl, label: "Instagram" },
|
||||
{ icon: Linkedin, url: siteInfo.linkedinUrl, label: "LinkedIn" },
|
||||
].filter(link => link.url && link.url.trim() !== "" && link.url !== "#");
|
||||
|
||||
return (
|
||||
<footer className="bg-dark-900 text-white">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
|
||||
<footer className="bg-dark-900 text-white border-t border-gray-800">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8 text-center sm:text-left">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-serif font-bold text-brand-500 mb-4">{siteInfo.siteName}</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<h3 className="text-2xl font-serif font-bold text-brand-500 mb-4 tracking-tight uppercase">{siteInfo.siteName}</h3>
|
||||
<p className="text-gray-400 text-sm mb-6 max-w-sm">
|
||||
{siteInfo.siteSlogan}
|
||||
</p>
|
||||
{socialLinks.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold text-xs uppercase tracking-widest text-gray-500">Suivez-nous</h4>
|
||||
<div className="flex space-x-4 justify-center sm:justify-start">
|
||||
{socialLinks.map((social, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-full border border-gray-800 text-gray-400 hover:text-brand-500 hover:border-brand-500/50 hover:bg-white/5 transition-all"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<social.icon size={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Liens Rapides</h4>
|
||||
|
||||
@@ -21,7 +21,7 @@ const Navbar = () => {
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div>
|
||||
<span className="font-serif font-bold text-xl text-gray-900">Afropreunariat</span>
|
||||
<span className="font-serif font-bold text-xl text-gray-900">Afrohub</span>
|
||||
</Link>
|
||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
||||
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
|
||||
@@ -1,80 +1,52 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Check, X, Smartphone, CreditCard, Loader, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
tier: string;
|
||||
name: string;
|
||||
priceXOF: string;
|
||||
priceEUR: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
recommended: boolean;
|
||||
color: string;
|
||||
offerLimit: number;
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
priceXOF: 'Gratuit',
|
||||
priceEUR: '0€',
|
||||
description: 'Pour démarrer votre présence en ligne.',
|
||||
features: [
|
||||
'Fiche entreprise basique',
|
||||
'Visible dans la recherche',
|
||||
'1 Offre produit/service',
|
||||
'Support par email'
|
||||
],
|
||||
color: 'gray'
|
||||
},
|
||||
{
|
||||
id: 'booster',
|
||||
name: 'Booster',
|
||||
priceXOF: '5.000 FCFA',
|
||||
priceEUR: '8€',
|
||||
description: 'L\'indispensable pour les entreprises en croissance.',
|
||||
recommended: true,
|
||||
features: [
|
||||
'Tout du plan Starter',
|
||||
'Badge "Vérifié" ✅',
|
||||
'Jusqu\'à 10 Offres produits',
|
||||
'Lien vers réseaux sociaux & Site Web',
|
||||
'Statistiques de base (Vues)'
|
||||
],
|
||||
color: 'brand'
|
||||
},
|
||||
{
|
||||
id: 'empire',
|
||||
name: 'Empire',
|
||||
priceXOF: '15.000 FCFA',
|
||||
priceEUR: '23€',
|
||||
description: 'Dominez votre marché avec une visibilité maximale.',
|
||||
features: [
|
||||
'Tout du plan Booster',
|
||||
'Badge "Recommandé" 🏆',
|
||||
'Offres illimitées',
|
||||
'Intégration vidéo Youtube',
|
||||
'Interview écrite sur le Blog',
|
||||
'Support prioritaire WhatsApp'
|
||||
],
|
||||
color: 'gray'
|
||||
}
|
||||
];
|
||||
|
||||
const PricingSection = () => {
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/plans');
|
||||
const data = await res.json();
|
||||
if (!data.error) {
|
||||
setPlans(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching plans:', error);
|
||||
} finally {
|
||||
setLoadingPlans(false);
|
||||
}
|
||||
};
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
// Payment State
|
||||
const [paymentStep, setPaymentStep] = useState<'method' | 'processing' | 'success'>('method');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'mobile_money' | 'card' | null>(null);
|
||||
|
||||
const handleSelectPlan = (plan: Plan) => {
|
||||
if (plan.id === 'starter') return; // Free plan, no payment
|
||||
if (plan.tier === 'STARTER') return; // Free plan, no payment
|
||||
setSelectedPlan(plan);
|
||||
setPaymentStep('method');
|
||||
setIsPaymentModalOpen(true);
|
||||
@@ -130,53 +102,68 @@ const PricingSection = () => {
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="mt-12 space-y-4 sm:mt-16 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0 xl:grid-cols-3">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.id} className={`rounded-2xl shadow-xl bg-white border-2 flex flex-col ${plan.recommended ? 'border-brand-500 ring-4 ring-brand-50 relative transform scale-105 z-10' : 'border-gray-100'}`}>
|
||||
{plan.recommended && (
|
||||
<div className="absolute top-0 inset-x-0 -mt-4 flex justify-center">
|
||||
<span className="bg-brand-500 text-white px-4 py-1 rounded-full text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||
Populaire
|
||||
</span>
|
||||
{loadingPlans ? (
|
||||
[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-2xl shadow-xl bg-white border border-gray-100 p-8 h-96 animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-10"></div>
|
||||
<div className="h-10 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="space-y-3 mt-10">
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 font-serif">{plan.name}</h3>
|
||||
<p className="mt-4 text-sm text-gray-500">{plan.description}</p>
|
||||
<div className="mt-8 flex items-baseline">
|
||||
<span className="text-4xl font-extrabold text-gray-900 tracking-tight">
|
||||
{plan.priceXOF === 'Gratuit' ? 'Gratuit' : (billingCycle === 'yearly' && plan.id !== 'starter' ? 'Sur Devis' : plan.priceXOF)}
|
||||
</span>
|
||||
{plan.priceXOF !== 'Gratuit' && <span className="ml-1 text-xl font-medium text-gray-500">/mois</span>}
|
||||
</div>
|
||||
{plan.priceXOF !== 'Gratuit' && (
|
||||
<p className="text-xs text-gray-400 mt-1">soit env. {plan.priceEUR} /mois</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
plans.map((plan) => (
|
||||
<div key={plan.id} className={`rounded-2xl shadow-xl bg-white border-2 flex flex-col ${plan.recommended ? 'border-brand-500 ring-4 ring-brand-50 relative transform scale-105 z-10' : 'border-gray-100'}`}>
|
||||
{plan.recommended && (
|
||||
<div className="absolute top-0 inset-x-0 -mt-4 flex justify-center">
|
||||
<span className="bg-brand-500 text-white px-4 py-1 rounded-full text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||
Populaire
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 font-serif">{plan.name}</h3>
|
||||
<p className="mt-4 text-sm text-gray-500">{plan.description}</p>
|
||||
<div className="mt-8 flex items-baseline">
|
||||
<span className="text-4xl font-extrabold text-gray-900 tracking-tight">
|
||||
{plan.priceXOF === 'Gratuit' ? 'Gratuit' : (billingCycle === 'yearly' && plan.tier !== 'STARTER' ? 'Sur Devis' : plan.priceXOF)}
|
||||
</span>
|
||||
{plan.priceXOF !== 'Gratuit' && <span className="ml-1 text-xl font-medium text-gray-500">/mois</span>}
|
||||
</div>
|
||||
{plan.priceXOF !== 'Gratuit' && (
|
||||
<p className="text-xs text-gray-400 mt-1">soit env. {plan.priceEUR} /mois</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<p className="ml-3 text-sm text-gray-700">{feature}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="mt-8 space-y-4">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<p className="ml-3 text-sm text-gray-700">{feature}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-b-2xl">
|
||||
<button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
className={`w-full block text-center rounded-lg border border-transparent px-6 py-3 text-base font-medium transition-colors ${
|
||||
plan.tier === 'STARTER'
|
||||
? 'text-brand-700 bg-brand-100 hover:bg-brand-200'
|
||||
: 'text-white bg-brand-600 hover:bg-brand-700 shadow-md hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{plan.tier === 'STARTER' ? 'Commencer Gratuitement' : `Choisir ${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-b-2xl">
|
||||
<button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
className={`w-full block text-center rounded-lg border border-transparent px-6 py-3 text-base font-medium transition-colors ${
|
||||
plan.id === 'starter'
|
||||
? 'text-brand-700 bg-brand-100 hover:bg-brand-200'
|
||||
: 'text-white bg-brand-600 hover:bg-brand-700 shadow-md hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{plan.id === 'starter' ? 'Commencer Gratuitement' : `Choisir ${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Offer, OfferType } from '../../types';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface DashboardOffersProps {
|
||||
businessId: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
|
||||
const DashboardOffers = ({ businessId, plan = 'STARTER' }: DashboardOffersProps) => {
|
||||
const [offers, setOffers] = useState<Offer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -155,19 +156,42 @@ const DashboardOffers = ({ businessId }: DashboardOffersProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const offerLimit = plan === 'BOOSTER' ? 10 : (plan === 'EMPIRE' ? 1000 : 1);
|
||||
const isLimitReached = offers.length >= offerLimit;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Mes Offres</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Utilisation : <span className="font-bold">{offers.length} / {offerLimit === 1000 ? 'Illimité' : offerLimit}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm transition-colors active:scale-95"
|
||||
disabled={isLimitReached}
|
||||
className={`flex items-center px-4 py-2 rounded-md font-medium text-sm shadow-sm transition-colors active:scale-95 ${isLimitReached ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-brand-600 text-white hover:bg-brand-700'}`}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Ajouter une offre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLimitReached && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
<p className="text-sm text-orange-800">
|
||||
Vous avez atteint la limite de votre plan <strong>{plan}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<button className="text-xs font-bold text-brand-600 hover:text-brand-700 uppercase tracking-tight">
|
||||
Passer au plan supérieur →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List View */}
|
||||
{offers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
@@ -3,61 +3,100 @@ import React from 'react';
|
||||
import { Eye, Star } from 'lucide-react';
|
||||
import { Business } from '../../types';
|
||||
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar
|
||||
} from 'recharts';
|
||||
|
||||
const MousePointerClick = ({className}: {className?:string}) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M14 4.1 12 6" /><path d="m5.1 8-2.9-.8" /><path d="m6 12-1.9 2" /><path d="M7.2 2.2 8 5.1" /><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DashboardOverview = ({ business, stats }: { business: Business, stats: { totalViews: number, contactClicks: number } | null }) => (
|
||||
interface DayStat {
|
||||
date: string;
|
||||
views: number;
|
||||
clicks: number;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, color }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-3 shadow-xl border border-gray-100 rounded-lg">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">{label}</p>
|
||||
<p className="text-lg font-bold" style={{ color }}>
|
||||
{payload[0].value} {payload[0].name === 'views' ? 'Vues' : 'Clics'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const DashboardOverview = ({ business, stats }: {
|
||||
business: Business,
|
||||
stats: {
|
||||
totalViews: number,
|
||||
contactClicks: number,
|
||||
dailyStats: DayStat[]
|
||||
} | null
|
||||
}) => (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Tableau de bord</h2>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-brand-100 rounded-md p-3">
|
||||
<div className="flex-shrink-0 bg-brand-50 rounded-lg p-3">
|
||||
<Eye className="h-6 w-6 text-brand-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Vues de la fiche</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats ? stats.totalViews : business.viewCount}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-blue-100 rounded-md p-3">
|
||||
<div className="flex-shrink-0 bg-blue-50 rounded-lg p-3">
|
||||
<MousePointerClick className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Clics Contact</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">{stats ? stats.contactClicks : '...'}</div>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats ? stats.contactClicks : '...'}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white overflow-hidden shadow-sm border border-gray-100 rounded-xl">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-yellow-100 rounded-md p-3">
|
||||
<div className="flex-shrink-0 bg-yellow-50 rounded-lg p-3">
|
||||
<Star className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Note moyenne</dt>
|
||||
<dd>
|
||||
<div className="text-lg font-medium text-gray-900">{business.rating}/5</div>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-bold text-gray-900">{business.rating}/5</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -66,11 +105,90 @@ const DashboardOverview = ({ business, stats }: { business: Business, stats: { t
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Performances 30 derniers jours</h3>
|
||||
<div className="h-64 bg-gray-50 rounded border border-dashed border-gray-200 flex items-center justify-center text-gray-400">
|
||||
Graphique des visites (À venir en Phase 2)
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900">Historique des Visites</h3>
|
||||
<div className="px-2 py-1 bg-brand-50 text-brand-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
|
||||
</div>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={stats?.dailyStats || []}>
|
||||
<defs>
|
||||
<linearGradient id="colorViews" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#d97706" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#d97706" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||
interval={6}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip color="#d97706" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
name="views"
|
||||
stroke="#d97706"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorViews)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow-sm border border-gray-100 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900">Interactions Contact</h3>
|
||||
<div className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-bold uppercase rounded leading-none">30 derniers jours</div>
|
||||
</div>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={stats?.dailyStats || []}>
|
||||
<defs>
|
||||
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||
interval={6}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{fill: '#9ca3af', fontSize: 10}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip color="#2563eb" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="clicks"
|
||||
name="clicks"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorClicks)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Business, CATEGORIES } from '../../types';
|
||||
import { Business, CATEGORIES, Country } from '../../types';
|
||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useUser } from '../UserProvider';
|
||||
@@ -15,17 +15,56 @@ const getYouTubeId = (url: string) => {
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const normalizeBusinessData = (b: Business): Business => ({
|
||||
...b,
|
||||
name: b.name || 'Nouvelle Entreprise',
|
||||
category: b.category || CATEGORIES[0],
|
||||
description: b.description || '',
|
||||
location: b.location || '',
|
||||
countryId: b.countryId || '',
|
||||
city: b.city || '',
|
||||
contactEmail: b.contactEmail || '',
|
||||
showEmail: b.showEmail ?? true,
|
||||
showPhone: b.showPhone ?? true,
|
||||
showSocials: b.showSocials ?? true,
|
||||
slug: b.slug || '',
|
||||
videoUrl: b.videoUrl || '',
|
||||
contactPhone: b.contactPhone || '',
|
||||
websiteUrl: b.websiteUrl || '',
|
||||
socialLinks: {
|
||||
facebook: b.socialLinks?.facebook || '',
|
||||
linkedin: b.socialLinks?.linkedin || '',
|
||||
instagram: b.socialLinks?.instagram || '',
|
||||
website: b.socialLinks?.website || '',
|
||||
}
|
||||
});
|
||||
|
||||
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
|
||||
const { login } = useUser();
|
||||
const [formData, setFormData] = useState(business);
|
||||
const [formData, setFormData] = useState<Business>(normalizeBusinessData(business));
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Fetch countries
|
||||
React.useEffect(() => {
|
||||
const fetchCountries = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/countries');
|
||||
const data = await res.json();
|
||||
if (!data.error) setCountries(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching countries:', error);
|
||||
}
|
||||
};
|
||||
fetchCountries();
|
||||
}, []);
|
||||
|
||||
// Sync formData when business prop changes (initial fetch)
|
||||
React.useEffect(() => {
|
||||
setFormData(business);
|
||||
setFormData(normalizeBusinessData(business));
|
||||
setVideoInput(business.videoUrl || '');
|
||||
setVideoPreviewId(getYouTubeId(business.videoUrl || ''));
|
||||
}, [business]);
|
||||
@@ -91,6 +130,38 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm("⚠️ ATTENTION : Êtes-vous sûr de vouloir supprimer définitivement votre boutique ? Cette action supprimera également vos offres, vos messages et vos statistiques. Cette action est irréversible.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/businesses/${business.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-user-id': business.ownerId
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toast.success('Boutique supprimée avec succès.');
|
||||
// Upgrade local storage user if role changed
|
||||
const localUser = JSON.parse(localStorage.getItem('afro_user') || '{}');
|
||||
if (localUser.id === business.ownerId) {
|
||||
localUser.role = 'VISITOR';
|
||||
localStorage.setItem('afro_user', JSON.stringify(localUser));
|
||||
if (login) login(localUser);
|
||||
}
|
||||
setTimeout(() => window.location.href = '/dashboard', 1500);
|
||||
} else {
|
||||
toast.error(data.error || 'Erreur lors de la suppression');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('Erreur réseau lors de la suppression');
|
||||
}
|
||||
};
|
||||
|
||||
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
|
||||
const isDescOk = formData.description && formData.description.length >= 20;
|
||||
const isLocOk = formData.location && formData.location !== "Ma Ville";
|
||||
@@ -254,17 +325,35 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Globe className="w-5 h-5 mr-2 text-blue-500" /> Coordonnées & Réseaux</h3>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Localisation (Ville, Pays)</label>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pays</label>
|
||||
<select
|
||||
name="countryId"
|
||||
value={formData.countryId || ''}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Sélectionner un pays</option>
|
||||
{countries.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.flag} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ville</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
name="city"
|
||||
value={formData.city || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ex: Abidjan, Côte d'Ivoire"
|
||||
placeholder="Ex: Abidjan"
|
||||
className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-6 hidden">
|
||||
{/* Keep legacy field for compatibility if needed, but hide it */}
|
||||
<input type="hidden" name="location" value={`${formData.city || ''}, ${countries.find(c => c.id === formData.countryId)?.name || ''}`} />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
||||
@@ -335,6 +424,29 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* D. Zone de Danger */}
|
||||
<div className="bg-red-50 border border-red-200 shadow-sm rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 mb-4 text-red-800">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<h3 className="text-lg font-bold">Zone de Danger</h3>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-red-900">Supprimer définitivement la boutique</p>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
Une fois supprimée, votre boutique ne sera plus visible dans l'annuaire.
|
||||
Toutes vos données (offres, messages, avis) seront perdues.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 font-bold text-sm shadow-sm transition-all active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
Supprimer ma boutique
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user