first commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
60
App.tsx
Normal file
60
App.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { User } from './types';
|
||||
import { MOCK_USER } from './services/mockData';
|
||||
|
||||
// Layout
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
// Pages
|
||||
import HomePage from './pages/HomePage';
|
||||
import DirectoryPage from './pages/DirectoryPage';
|
||||
import BlogPage from './pages/BlogPage';
|
||||
import BlogPostPage from './pages/BlogPostPage';
|
||||
import BusinessDetailPage from './pages/BusinessDetailPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import AfroLifePage from './pages/AfroLifePage';
|
||||
import InterviewDetailPage from './pages/InterviewDetailPage';
|
||||
import SubscriptionPage from './pages/SubscriptionPage';
|
||||
|
||||
const App = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
const handleLogin = () => {
|
||||
setUser(MOCK_USER);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
// Redirect handled by rendering logic
|
||||
window.location.hash = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<div className="min-h-screen flex flex-col bg-gray-50 font-sans">
|
||||
<Navbar user={user} onLogout={handleLogout} />
|
||||
<main className="flex-grow">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/directory" element={<DirectoryPage />} />
|
||||
<Route path="/directory/:id" element={<BusinessDetailPage />} />
|
||||
<Route path="/blog" element={<BlogPage />} />
|
||||
<Route path="/blog/:id" element={<BlogPostPage />} />
|
||||
<Route path="/afrolife" element={<AfroLifePage />} />
|
||||
<Route path="/afrolife/:id" element={<InterviewDetailPage />} />
|
||||
<Route path="/subscription" element={<SubscriptionPage />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
|
||||
<Route path="/dashboard" element={user ? <DashboardPage user={user} onLogout={handleLogout} /> : <LoginPage onLogin={handleLogin} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</HashRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1Hb3LjU1spRnNuk8yMiBYkrIwYFSfTa9j
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
132
components/BusinessCard.tsx
Normal file
132
components/BusinessCard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CheckCircle, MapPin, Star, Phone, Share2, Facebook, Linkedin, Instagram, Twitter } from 'lucide-react';
|
||||
import { Business } from '../types';
|
||||
|
||||
const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
|
||||
const toggleShare = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsShareOpen(!isShareOpen);
|
||||
};
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
const url = encodeURIComponent(`${window.location.origin}/#/directory/${business.id}`);
|
||||
const text = encodeURIComponent(`Découvrez ${business.name} sur Afropreunariat`);
|
||||
|
||||
let shareLink = '';
|
||||
switch(platform) {
|
||||
case 'facebook':
|
||||
shareLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
|
||||
break;
|
||||
case 'twitter':
|
||||
shareLink = `https://twitter.com/intent/tweet?url=${url}&text=${text}`;
|
||||
break;
|
||||
case 'linkedin':
|
||||
shareLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
|
||||
break;
|
||||
case 'instagram':
|
||||
// Instagram web share is limited, alerting user
|
||||
alert("Pour partager sur Instagram, copiez le lien ou faites une capture d'écran.");
|
||||
setIsShareOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareLink) {
|
||||
window.open(shareLink, '_blank', 'width=600,height=400');
|
||||
}
|
||||
setIsShareOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-100 overflow-hidden flex flex-col h-full group relative">
|
||||
{/* Header with Image and Actions */}
|
||||
<div className="relative h-40 bg-gray-200">
|
||||
<img
|
||||
src={`https://picsum.photos/seed/${business.id}/500/300`}
|
||||
alt="Couverture"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-2 right-2 flex gap-2 z-20">
|
||||
{business.contactPhone && (
|
||||
<a
|
||||
href={`tel:${business.contactPhone}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 bg-white/20 backdrop-blur-md border border-white/20 rounded-full text-white hover:bg-white hover:text-brand-600 transition-all shadow-sm"
|
||||
title="Appeler"
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleShare}
|
||||
className="p-2 bg-white/20 backdrop-blur-md border border-white/20 rounded-full text-white hover:bg-white hover:text-brand-600 transition-all shadow-sm"
|
||||
title="Partager"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{isShareOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-xl py-2 border border-gray-100 z-30 animate-in fade-in zoom-in duration-200" onMouseLeave={() => setIsShareOpen(false)}>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Partager</div>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShare('facebook'); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-600">
|
||||
<Facebook className="w-4 h-4 mr-3" /> Facebook
|
||||
</button>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShare('twitter'); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-black">
|
||||
<Twitter className="w-4 h-4 mr-3" /> X (Twitter)
|
||||
</button>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShare('linkedin'); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-blue-700">
|
||||
<Linkedin className="w-4 h-4 mr-3" /> LinkedIn
|
||||
</button>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShare('instagram'); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center hover:text-pink-600">
|
||||
<Instagram className="w-4 h-4 mr-3" /> Instagram
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="absolute -bottom-6 left-4 z-10 w-16 h-16 bg-white rounded-lg p-1 shadow-md">
|
||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-md bg-gray-50" />
|
||||
</div>
|
||||
|
||||
<Link to={`/directory/${business.id}`} className="absolute inset-0 z-0" aria-label={`Voir ${business.name}`} />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="pt-8 px-4 pb-4 flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start">
|
||||
<Link to={`/directory/${business.id}`} className="font-bold text-lg text-gray-900 truncate hover:text-brand-600 transition-colors">
|
||||
{business.name}
|
||||
</Link>
|
||||
{business.verified && <CheckCircle className="w-5 h-5 text-blue-500 flex-shrink-0 ml-1" />}
|
||||
</div>
|
||||
<p className="text-xs text-brand-600 font-semibold mb-2 uppercase tracking-wide">{business.category}</p>
|
||||
<div className="flex items-center text-sm text-gray-500 mb-3">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
{business.location}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-4 flex-1">{business.description}</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<div className="flex items-center">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-current" />
|
||||
<span className="text-sm font-bold ml-1 text-gray-700">{business.rating}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">({business.viewCount} vues)</span>
|
||||
</div>
|
||||
<Link to={`/directory/${business.id}`} className="text-brand-600 text-sm font-medium hover:underline">Voir la fiche</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessCard;
|
||||
94
components/DirectoryHero.tsx
Normal file
94
components/DirectoryHero.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { User as UserIcon, Award, Briefcase, MapPin, ArrowRight } from 'lucide-react';
|
||||
import { Business } from '../types';
|
||||
|
||||
const DirectoryHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
|
||||
if (!featuredBusiness) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 relative overflow-hidden pb-12 pt-6">
|
||||
{/* Geometric Background Pattern */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<svg className="h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="#ea580c" />
|
||||
<path d="M0 0 C 50 100 80 100 100 0 Z" fill="#ea580c" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Main Card */}
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col md:flex-row border border-gray-100">
|
||||
|
||||
{/* Left: Imagery (Founder + Logo) */}
|
||||
<div className="md:w-2/5 relative h-64 md:h-auto bg-gray-900 group">
|
||||
{featuredBusiness.founderImageUrl ? (
|
||||
<img
|
||||
src={featuredBusiness.founderImageUrl}
|
||||
alt={featuredBusiness.founderName}
|
||||
className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-gray-800 to-black flex items-center justify-center">
|
||||
<UserIcon className="w-20 h-20 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
|
||||
{/* Overlaid Logo */}
|
||||
<div className="absolute bottom-6 left-6 flex items-center space-x-3">
|
||||
<div className="w-16 h-16 bg-white rounded-xl p-1 shadow-lg rotate-3 transform transition-transform group-hover:rotate-0">
|
||||
<img src={featuredBusiness.logoUrl} alt="Logo" className="w-full h-full object-cover rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Content */}
|
||||
<div className="md:w-3/5 p-8 md:p-12 flex flex-col justify-center">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-800 uppercase tracking-wider">
|
||||
<Award className="w-3 h-3 mr-1" /> Entrepreneur du mois
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
|
||||
{featuredBusiness.name}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-4 font-medium">
|
||||
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
|
||||
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>
|
||||
<div className="flex items-center"><MapPin className="w-4 h-4 mr-1"/> {featuredBusiness.location}</div>
|
||||
</div>
|
||||
|
||||
{/* Shocking Figure */}
|
||||
{featuredBusiness.keyMetric && (
|
||||
<div className="mb-8 p-4 bg-brand-50 border-l-4 border-brand-500 rounded-r-lg">
|
||||
<p className="text-2xl font-bold text-brand-900">{featuredBusiness.keyMetric}</p>
|
||||
<p className="text-xs text-brand-700 uppercase font-semibold">Performance validée</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
to={`/directory/${featuredBusiness.id}`}
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Voir la fiche complète
|
||||
</Link>
|
||||
<button className="inline-flex items-center justify-center px-6 py-3 border-2 border-gray-200 text-base font-medium rounded-md text-gray-600 bg-transparent hover:border-brand-300 hover:text-brand-600 transition-colors">
|
||||
Devenir l'entrepreneur du mois <ArrowRight className="w-4 h-4 ml-2"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryHero;
|
||||
42
components/Footer.tsx
Normal file
42
components/Footer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const Footer = () => {
|
||||
const location = useLocation();
|
||||
if (location.pathname.startsWith('/dashboard')) return null;
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-serif font-bold text-brand-500 mb-4">Afropreunariat</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
La plateforme de référence pour l'entrepreneuriat africain.
|
||||
Visibilité, connexion et croissance pour les TPE/PME.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Liens Rapides</h4>
|
||||
<ul className="space-y-2 text-gray-400 text-sm">
|
||||
<li><Link to="/directory" className="hover:text-brand-500">Rechercher une entreprise</Link></li>
|
||||
<li><Link to="/login" className="hover:text-brand-500">Inscrire mon entreprise</Link></li>
|
||||
<li><Link to="/blog" className="hover:text-brand-500">Actualités & Conseils</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Contact</h4>
|
||||
<p className="text-gray-400 text-sm mb-2">Abidjan, Côte d'Ivoire / Paris, France</p>
|
||||
<p className="text-gray-400 text-sm">support@afropreunariat.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t border-gray-800 text-center text-gray-500 text-sm">
|
||||
© 2025 Afropreunariat. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
86
components/Navbar.tsx
Normal file
86
components/Navbar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Menu, X, LayoutDashboard, User as UserIcon } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
|
||||
interface NavbarProps {
|
||||
user: User | null;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ user, onLogout }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Hide Navbar on Dashboard to prevent double navigation, show only on public pages
|
||||
if (location.pathname.startsWith('/dashboard')) return null;
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" 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>
|
||||
</Link>
|
||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
||||
<Link to="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${location.pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
Accueil
|
||||
</Link>
|
||||
<Link to="/directory" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${location.pathname.startsWith('/directory') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
Annuaire
|
||||
</Link>
|
||||
<Link to="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${location.pathname.startsWith('/afrolife') ? 'border-brand-500 text-brand-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
Afro Life
|
||||
</Link>
|
||||
<Link to="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${location.pathname.startsWith('/blog') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center space-x-4">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none">
|
||||
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||
Mon Espace
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
|
||||
<UserIcon className="w-4 h-4 mr-2" />
|
||||
Connexion / Inscription
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mr-2 flex items-center sm:hidden">
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none">
|
||||
{isOpen ? <X className="block h-6 w-6" /> : <Menu className="block h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isOpen && (
|
||||
<div className="sm:hidden bg-white border-t">
|
||||
<div className="pt-2 pb-3 space-y-1">
|
||||
<Link to="/" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Accueil</Link>
|
||||
<Link to="/directory" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Annuaire</Link>
|
||||
<Link to="/afrolife" className="block pl-3 pr-4 py-2 border-l-4 border-brand-500 text-base font-medium text-brand-700 bg-brand-50" onClick={() => setIsOpen(false)}>Afro Life</Link>
|
||||
<Link to="/blog" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Blog</Link>
|
||||
{user ? (
|
||||
<Link to="/dashboard" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Mon Espace</Link>
|
||||
) : (
|
||||
<Link to="/login" className="block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" onClick={() => setIsOpen(false)}>Connexion</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
325
components/PricingSection.tsx
Normal file
325
components/PricingSection.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Check, X, Smartphone, CreditCard, Loader, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
priceXOF: string;
|
||||
priceEUR: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
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 [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||
|
||||
// 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
|
||||
setSelectedPlan(plan);
|
||||
setPaymentStep('method');
|
||||
setIsPaymentModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePayment = () => {
|
||||
setPaymentStep('processing');
|
||||
// Fake API delay
|
||||
setTimeout(() => {
|
||||
setPaymentStep('success');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const closePayment = () => {
|
||||
setIsPaymentModalOpen(false);
|
||||
setPaymentStep('method');
|
||||
setSelectedPlan(null);
|
||||
setPaymentMethod(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-base font-semibold text-brand-600 tracking-wide uppercase">Tarifs</h2>
|
||||
<p className="mt-1 text-4xl font-extrabold text-gray-900 sm:text-5xl sm:tracking-tight lg:text-6xl font-serif">
|
||||
Investissez dans votre croissance
|
||||
</p>
|
||||
<p className="max-w-xl mt-5 mx-auto text-xl text-gray-500">
|
||||
Choisissez le plan adapté à vos ambitions. Changez ou annulez à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="mt-12 flex justify-center">
|
||||
<div className="relative bg-white rounded-lg p-0.5 flex sm:mt-0 shadow-sm border border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`${billingCycle === 'monthly' ? 'bg-gray-100 border-gray-200 shadow-sm text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700'} relative w-1/2 whitespace-nowrap py-2 px-6 rounded-md text-sm font-medium focus:outline-none transition-all sm:w-auto sm:px-8`}
|
||||
>
|
||||
Mensuel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
className={`${billingCycle === 'yearly' ? 'bg-gray-100 border-gray-200 shadow-sm text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700'} relative w-1/2 whitespace-nowrap py-2 px-6 rounded-md text-sm font-medium focus:outline-none transition-all sm:w-auto sm:px-8`}
|
||||
>
|
||||
Annuel <span className="text-brand-600 text-xs ml-1 font-bold">-20%</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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.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>
|
||||
|
||||
{/* FAKE PAYMENT MODAL */}
|
||||
{isPaymentModalOpen && selectedPlan && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Overlay */}
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={closePayment}></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 font-serif" id="modal-title">
|
||||
Paiement Sécurisé
|
||||
</h3>
|
||||
<button onClick={closePayment} className="text-gray-400 hover:text-gray-500">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content based on step */}
|
||||
{paymentStep === 'method' && (
|
||||
<>
|
||||
<div className="bg-brand-50 p-4 rounded-md mb-6 border border-brand-100">
|
||||
<p className="text-sm text-brand-800 font-medium">Vous avez choisi le plan <span className="font-bold">{selectedPlan.name}</span></p>
|
||||
<p className="text-2xl font-bold text-brand-900 mt-1">{selectedPlan.priceXOF}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Moyen de paiement</p>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('mobile_money')}
|
||||
className={`flex items-center p-4 border rounded-lg hover:bg-gray-50 transition-all ${paymentMethod === 'mobile_money' ? 'border-brand-500 ring-1 ring-brand-500 bg-brand-50' : 'border-gray-300'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-orange-100 rounded-full flex items-center justify-center text-orange-600 mr-4">
|
||||
<Smartphone className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Mobile Money</p>
|
||||
<p className="text-xs text-gray-500">Orange Money, MTN, Wave, Moov</p>
|
||||
</div>
|
||||
<div className="h-4 w-4 rounded-full border border-gray-300 flex items-center justify-center">
|
||||
{paymentMethod === 'mobile_money' && <div className="h-2 w-2 rounded-full bg-brand-600"></div>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`flex items-center p-4 border rounded-lg hover:bg-gray-50 transition-all ${paymentMethod === 'card' ? 'border-brand-500 ring-1 ring-brand-500 bg-brand-50' : 'border-gray-300'}`}
|
||||
>
|
||||
<div className="h-10 w-10 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 mr-4">
|
||||
<CreditCard className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="font-bold text-gray-900">Carte Bancaire</p>
|
||||
<p className="text-xs text-gray-500">Visa, Mastercard</p>
|
||||
</div>
|
||||
<div className="h-4 w-4 rounded-full border border-gray-300 flex items-center justify-center">
|
||||
{paymentMethod === 'card' && <div className="h-2 w-2 rounded-full bg-brand-600"></div>}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentMethod === 'mobile_money' && (
|
||||
<div className="mt-4 animate-in fade-in slide-in-from-top-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Numéro de téléphone</label>
|
||||
<input type="text" placeholder="+225 07..." className="mt-1 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>
|
||||
)}
|
||||
{paymentMethod === 'card' && (
|
||||
<div className="mt-4 animate-in fade-in slide-in-from-top-2 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Numéro de carte</label>
|
||||
<input type="text" placeholder="0000 0000 0000 0000" className="mt-1 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="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Expiration</label>
|
||||
<input type="text" placeholder="MM/AA" className="mt-1 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>
|
||||
<label className="block text-sm font-medium text-gray-700">CVC</label>
|
||||
<input type="text" placeholder="123" className="mt-1 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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentStep === 'processing' && (
|
||||
<div className="py-10 text-center">
|
||||
<Loader className="h-12 w-12 text-brand-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-lg font-medium text-gray-900">Traitement en cours...</p>
|
||||
<p className="text-sm text-gray-500 mt-2">Veuillez valider la transaction sur votre mobile si nécessaire.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentStep === 'success' && (
|
||||
<div className="py-6 text-center animate-in zoom-in duration-300">
|
||||
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-4">
|
||||
<ShieldCheck className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Paiement Réussi !</h3>
|
||||
<p className="text-gray-500 mt-2 mb-6">Votre abonnement {selectedPlan.name} est maintenant actif.</p>
|
||||
<button
|
||||
onClick={closePayment}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none sm:text-sm"
|
||||
>
|
||||
Accéder à mon espace
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentStep === 'method' && (
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!paymentMethod}
|
||||
onClick={handlePayment}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Payer {selectedPlan.priceXOF}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePayment}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingSection;
|
||||
192
components/dashboard/DashboardOffers.tsx
Normal file
192
components/dashboard/DashboardOffers.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { PlusCircle, Edit3, Trash2, ShoppingBag, Image as ImageIcon } from 'lucide-react';
|
||||
import { Offer, OfferType } from '../../types';
|
||||
import { MOCK_OFFERS } from '../../services/mockData';
|
||||
|
||||
const DashboardOffers = () => {
|
||||
const [offers, setOffers] = useState<Offer[]>(MOCK_OFFERS);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentOffer, setCurrentOffer] = useState<Partial<Offer>>({
|
||||
title: '',
|
||||
price: 0,
|
||||
currency: 'XOF',
|
||||
type: OfferType.SERVICE
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm("Voulez-vous vraiment supprimer cette offre ?")) {
|
||||
setOffers(offers.filter(o => o.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (offer: Offer) => {
|
||||
setCurrentOffer(offer);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setCurrentOffer({
|
||||
title: '',
|
||||
price: 0,
|
||||
currency: 'XOF',
|
||||
type: OfferType.SERVICE
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (currentOffer.id) {
|
||||
// Edit mode
|
||||
setOffers(offers.map(o => o.id === currentOffer.id ? { ...o, ...currentOffer } as Offer : o));
|
||||
} else {
|
||||
// Add mode
|
||||
const offer: Offer = {
|
||||
id: Date.now().toString(),
|
||||
businessId: '1',
|
||||
title: currentOffer.title || 'Nouvelle offre',
|
||||
type: currentOffer.type || OfferType.PRODUCT,
|
||||
price: currentOffer.price || 0,
|
||||
currency: currentOffer.currency as 'EUR'|'XOF',
|
||||
imageUrl: currentOffer.imageUrl || 'https://picsum.photos/300/200?random=' + Date.now(),
|
||||
active: true
|
||||
};
|
||||
setOffers([...offers, offer]);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
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>
|
||||
<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">
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Ajouter une offre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List View */}
|
||||
{offers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{offers.map(offer => (
|
||||
<div key={offer.id} className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden group">
|
||||
<div className="relative h-40 bg-gray-200">
|
||||
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-gray-900 truncate">{offer.title}</h3>
|
||||
<p className="text-brand-600 font-bold mt-1">
|
||||
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-between items-center pt-4 border-t border-gray-100">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${offer.active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{offer.active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={() => handleEdit(offer)} className="p-1 text-gray-400 hover:text-brand-600" title="Modifier">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(offer.id)} className="p-1 text-gray-400 hover:text-red-600" title="Supprimer">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg border-2 border-dashed border-gray-300">
|
||||
<ShoppingBag className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune offre</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Commencez à vendre vos produits ou services.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal / Drawer */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={() => setIsModalOpen(false)}></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-brand-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<PlusCircle className="h-6 w-6 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{currentOffer.id ? 'Modifier l\'offre' : 'Ajouter une offre'}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Type d'offre</label>
|
||||
<div className="mt-2 flex space-x-4">
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-600" name="type" checked={currentOffer.type === OfferType.PRODUCT} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.PRODUCT})} />
|
||||
<span className="ml-2 text-sm">Produit Physique</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-600" name="type" checked={currentOffer.type === OfferType.SERVICE} onChange={() => setCurrentOffer({...currentOffer, type: OfferType.SERVICE})} />
|
||||
<span className="ml-2 text-sm">Service / Prestation</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Titre</label>
|
||||
<input type="text" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.title} onChange={e => setCurrentOffer({...currentOffer, title: e.target.value})} placeholder="Ex: Savon Karité Bio" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Prix</label>
|
||||
<input type="number" required min="0" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.price} onChange={e => setCurrentOffer({...currentOffer, price: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Devise</label>
|
||||
<select className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm" value={currentOffer.currency} onChange={e => setCurrentOffer({...currentOffer, currency: e.target.value as any})}>
|
||||
<option value="XOF">FCFA (XOF)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Photo</label>
|
||||
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
|
||||
<div className="space-y-1 text-center">
|
||||
<ImageIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="text-xs text-gray-500">PNG, JPG jusqu'à 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-600 text-base font-medium text-white hover:bg-brand-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
|
||||
{currentOffer.id ? 'Mettre à jour' : 'Publier l\'offre'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardOffers;
|
||||
78
components/dashboard/DashboardOverview.tsx
Normal file
78
components/dashboard/DashboardOverview.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Eye, Star } from 'lucide-react';
|
||||
import { Business } from '../../types';
|
||||
|
||||
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 }: { business: Business }) => (
|
||||
<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="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-brand-100 rounded-md 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">{business.viewCount}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-blue-100 rounded-md 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">42</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 bg-yellow-100 rounded-md 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>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardOverview;
|
||||
185
components/dashboard/DashboardProfile.tsx
Normal file
185
components/dashboard/DashboardProfile.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram } from 'lucide-react';
|
||||
import { Business, CATEGORIES } from '../../types';
|
||||
import { generateBusinessDescription } from '../../services/geminiService';
|
||||
|
||||
// Helper to extract youtube ID
|
||||
const getYouTubeId = (url: string) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const DashboardProfile = ({ business, setBusiness }: { business: Business, setBusiness: (b: Business) => void }) => {
|
||||
const [formData, setFormData] = useState(business);
|
||||
const [videoInput, setVideoInput] = useState(business.videoUrl || '');
|
||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSocialChange = (network: keyof typeof formData.socialLinks, value: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
socialLinks: { ...formData.socialLinks, [network]: value }
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const url = e.target.value;
|
||||
setVideoInput(url);
|
||||
setVideoPreviewId(getYouTubeId(url));
|
||||
setFormData({ ...formData, videoUrl: url });
|
||||
};
|
||||
|
||||
const handleAiGenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
const text = await generateBusinessDescription(formData.name, formData.category, "Innovation, Qualité, Service client");
|
||||
setFormData(prev => ({ ...prev, description: text }));
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setBusiness(formData);
|
||||
alert('Profil mis à jour avec succès !');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold font-serif text-gray-900">Éditer mon profil</h2>
|
||||
<button onClick={handleSave} className="bg-brand-600 text-white px-4 py-2 rounded-md hover:bg-brand-700 font-medium text-sm shadow-sm">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* A. Bloc Identité Visuelle */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><ImageIcon className="w-5 h-5 mr-2 text-brand-600"/> Identité Visuelle</h3>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="shrink-0">
|
||||
<img className="h-24 w-24 object-cover rounded-full border-2 border-gray-200" src={formData.logoUrl} alt="Logo actuel" />
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center hover:border-brand-400 transition-colors cursor-pointer bg-gray-50">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
<p className="mt-1 text-xs text-gray-500">Glissez votre logo ici ou cliquez pour parcourir</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Nom de l'entreprise</label>
|
||||
<input type="text" name="name" value={formData.name} onChange={handleInputChange} className="mt-1 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-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Secteur d'activité</label>
|
||||
<select name="category" value={formData.category} onChange={handleInputChange} className="mt-1 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">
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-6">
|
||||
<div className="flex justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Description</label>
|
||||
<button type="button" onClick={handleAiGenerate} disabled={isGenerating} className="text-xs flex items-center text-brand-600 hover:text-brand-800">
|
||||
{isGenerating ? '...' : <><Sparkles className="w-3 h-3 mr-1"/> Générer avec IA</>}
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="description" rows={3} value={formData.description} 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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* B. Bloc Présentation Vidéo */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><Youtube className="w-5 h-5 mr-2 text-red-600"/> Présentation Vidéo</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Lien de votre vidéo (Youtube)</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={videoInput.replace('https://', '')}
|
||||
onChange={handleVideoChange}
|
||||
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300"
|
||||
placeholder="www.youtube.com/watch?v=..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">Copiez l'URL de votre vidéo de présentation pour l'afficher sur votre profil.</p>
|
||||
</div>
|
||||
|
||||
{videoPreviewId ? (
|
||||
<div className="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden bg-gray-100 mt-4 border border-gray-200">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoPreviewId}`}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="w-full h-64 sm:h-80 rounded-lg"
|
||||
></iframe>
|
||||
</div>
|
||||
) : videoInput && (
|
||||
<div className="rounded-md bg-red-50 p-4 mt-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<X className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Lien invalide</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>Impossible de détecter une vidéo YouTube valide. Vérifiez le lien.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* C. Bloc Coordonnées & Réseaux */}
|
||||
<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-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Email Contact</label>
|
||||
<input type="email" name="contactEmail" value={formData.contactEmail} onChange={handleInputChange} className="mt-1 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-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Téléphone</label>
|
||||
<input type="text" name="contactPhone" value={formData.contactPhone || ''} onChange={handleInputChange} className="mt-1 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 border-t border-gray-100 pt-4 mt-2">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-3 uppercase">Réseaux Sociaux</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
|
||||
<Facebook className="w-4 h-4"/>
|
||||
</span>
|
||||
<input type="text" placeholder="Lien Facebook" value={formData.socialLinks?.facebook || ''} onChange={(e) => handleSocialChange('facebook', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
|
||||
</div>
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
|
||||
<Linkedin className="w-4 h-4"/>
|
||||
</span>
|
||||
<input type="text" placeholder="Lien LinkedIn" value={formData.socialLinks?.linkedin || ''} onChange={(e) => handleSocialChange('linkedin', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
|
||||
</div>
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
|
||||
<Instagram className="w-4 h-4"/>
|
||||
</span>
|
||||
<input type="text" placeholder="Lien Instagram" value={formData.socialLinks?.instagram || ''} onChange={(e) => handleSocialChange('instagram', e.target.value)} className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md focus:ring-brand-500 focus:border-brand-500 sm:text-sm border-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardProfile;
|
||||
54
index.html
Normal file
54
index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="https://cdn-icons-png.flaticon.com/512/1022/1022235.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Afropreunariat - L'Annuaire 2.0</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
500: '#f97316', // Orange/Terra Cotta
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
900: '#7c2d12',
|
||||
},
|
||||
dark: {
|
||||
800: '#1e1e1e',
|
||||
900: '#121212',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
|
||||
"react-router-dom": "https://aistudiocdn.com/react-router-dom@^7.9.6",
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
Normal file
15
index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Afropreunariat",
|
||||
"description": "Plateforme de référencement et de mise en relation pour l'écosystème entrepreneurial africain et la diaspora.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
2816
package-lock.json
generated
Normal file
2816
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "afropreunariat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"lucide-react": "^0.554.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
108
pages/AfroLifePage.tsx
Normal file
108
pages/AfroLifePage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Play, FileText, Clock, Mic } from 'lucide-react';
|
||||
import { MOCK_INTERVIEWS } from '../services/mockData';
|
||||
import { InterviewType } from '../types';
|
||||
|
||||
const AfroLifePage = () => {
|
||||
const [filter, setFilter] = useState<'ALL' | 'VIDEO' | 'ARTICLE'>('ALL');
|
||||
|
||||
const filteredInterviews = MOCK_INTERVIEWS.filter(interview => {
|
||||
if (filter === 'ALL') return true;
|
||||
return interview.type === filter;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
{/* Hero Header */}
|
||||
<div className="bg-dark-900 text-white py-20 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-30 bg-[url('https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80')] bg-cover bg-center"></div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<span className="inline-block py-1 px-3 rounded-full bg-brand-600/20 border border-brand-500 text-brand-400 text-xs font-bold tracking-wider uppercase mb-4">
|
||||
Lifestyle & Inspiration
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-serif font-bold mb-6">Afro Life</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Plongez dans l'intimité des bâtisseurs. Entretiens exclusifs, parcours de vie et leçons de leadership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Filters */}
|
||||
<div className="flex justify-center mb-12 space-x-4">
|
||||
<button
|
||||
onClick={() => setFilter('ALL')}
|
||||
className={`px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'ALL' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
Tout voir
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('VIDEO')}
|
||||
className={`flex items-center px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'VIDEO' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" /> Vidéos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('ARTICLE')}
|
||||
className={`flex items-center px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'ARTICLE' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" /> Articles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredInterviews.map(interview => (
|
||||
<Link to={`/afrolife/${interview.id}`} key={interview.id} className="group block h-full">
|
||||
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
|
||||
<img
|
||||
src={interview.thumbnailUrl}
|
||||
alt={interview.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${interview.type === InterviewType.VIDEO ? 'bg-red-600/90' : 'bg-blue-600/90'}`}>
|
||||
{interview.type === InterviewType.VIDEO ? <Play className="w-3 h-3 mr-1 fill-current" /> : <Mic className="w-3 h-3 mr-1" />}
|
||||
{interview.type === InterviewType.VIDEO ? 'Vidéo' : 'Interview'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Play Icon Overlay for Videos */}
|
||||
{interview.type === InterviewType.VIDEO && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
|
||||
<Play className="w-8 h-8 text-white fill-current ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-6 px-2">
|
||||
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
|
||||
<span className="font-semibold text-brand-600 uppercase">{interview.guestName}</span>
|
||||
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
||||
<span>{interview.companyName}</span>
|
||||
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
|
||||
<span className="flex items-center"><Clock className="w-3 h-3 mr-1"/> {interview.duration}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight">
|
||||
{interview.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm line-clamp-2">
|
||||
{interview.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AfroLifePage;
|
||||
56
pages/BlogPage.tsx
Normal file
56
pages/BlogPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { MOCK_BLOG_POSTS } from '../services/mockData';
|
||||
|
||||
const BlogPage = () => (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Le Blog de l'Entrepreneur</h1>
|
||||
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
|
||||
Conseils, actualités et success stories de l'écosystème africain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{MOCK_BLOG_POSTS.map(post => (
|
||||
<Link key={post.id} to={`/blog/${post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
||||
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||
</div>
|
||||
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-brand-600">Conseils</p>
|
||||
<div className="block mt-2">
|
||||
<p className="text-xl font-semibold text-gray-900 group-hover:text-brand-700 transition-colors">{post.title}</p>
|
||||
<p className="mt-3 text-base text-gray-500 line-clamp-3">{post.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="sr-only">{post.author}</span>
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-bold border border-gray-300">
|
||||
{post.author.charAt(0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">{post.author}</p>
|
||||
<div className="flex space-x-1 text-sm text-gray-500">
|
||||
<time>{post.date}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-brand-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BlogPage;
|
||||
97
pages/BlogPostPage.tsx
Normal file
97
pages/BlogPostPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
|
||||
import { MOCK_BLOG_POSTS } from '../services/mockData';
|
||||
|
||||
const BlogPostPage = () => {
|
||||
const { id } = useParams();
|
||||
const post = MOCK_BLOG_POSTS.find(p => p.id === id);
|
||||
|
||||
// Scroll to top when loading a new post
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center">
|
||||
<h2 className="text-3xl font-serif font-bold text-gray-900 mb-4">Article introuvable</h2>
|
||||
<p className="text-gray-600 mb-8">L'article que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<Link to="/blog" className="text-brand-600 hover:text-brand-700 font-medium flex items-center">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour au blog
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen">
|
||||
{/* Hero Image */}
|
||||
<div className="w-full h-64 md:h-96 relative">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 w-full p-4 sm:p-8 max-w-4xl mx-auto">
|
||||
<Link to="/blog" className="inline-flex items-center text-white/80 hover:text-white mb-4 text-sm font-medium transition-colors">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12 -mt-20 relative z-10">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 md:p-10 border border-gray-100">
|
||||
{/* Header */}
|
||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<span className="flex items-center">
|
||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{post.date}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
Conseils
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
{/* Rendering paragraphs manually for the mock data */}
|
||||
{post.content.split('\n').map((paragraph, index) => (
|
||||
paragraph.trim() !== '' && (
|
||||
<p key={index} className="mb-4 leading-relaxed">
|
||||
{paragraph}
|
||||
</p>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer / Share */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||
<div className="flex space-x-2">
|
||||
<button className="p-2 rounded-full bg-gray-100 text-gray-600 hover:bg-brand-100 hover:text-brand-600 transition-colors">
|
||||
<Share2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostPage;
|
||||
353
pages/BusinessDetailPage.tsx
Normal file
353
pages/BusinessDetailPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Linkedin, Instagram, Play, Share2, Send, Award, Quote } from 'lucide-react';
|
||||
import { MOCK_BUSINESSES, MOCK_OFFERS } from '../services/mockData';
|
||||
import { OfferType } from '../types';
|
||||
|
||||
const BusinessDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const business = MOCK_BUSINESSES.find(b => b.id === id);
|
||||
|
||||
const [contactForm, setContactForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
const [formStatus, setFormStatus] = useState<'idle' | 'sending' | 'success'>('idle');
|
||||
|
||||
const businessOffers = useMemo(() => {
|
||||
return MOCK_OFFERS.filter(o => o.businessId === id && o.active);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
if (!business) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Entreprise introuvable</h2>
|
||||
<Link to="/directory" className="text-brand-600 hover:text-brand-700 font-medium flex items-center">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour à l'annuaire
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get YouTube embed URL
|
||||
const getEmbedUrl = (url: string) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
|
||||
};
|
||||
|
||||
const videoEmbedUrl = business.videoUrl ? getEmbedUrl(business.videoUrl) : null;
|
||||
|
||||
const handleContactSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormStatus('sending');
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setFormStatus('success');
|
||||
setContactForm({ name: '', email: '', message: '' });
|
||||
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setFormStatus('idle'), 3000);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen pb-12">
|
||||
{/* Header Banner */}
|
||||
<div className="h-48 md:h-64 bg-gradient-to-r from-gray-900 to-gray-800 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6">
|
||||
<Link to="/directory" className="inline-flex items-center text-white/80 hover:text-white transition-colors w-fit">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 md:p-8">
|
||||
{/* Profile Header */}
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative">
|
||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
|
||||
{business.isFeatured && (
|
||||
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Entrepreneur du Mois">
|
||||
<Award className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-serif font-bold text-gray-900 flex items-center gap-2">
|
||||
{business.name}
|
||||
</h1>
|
||||
{business.verified && <CheckCircle className="w-6 h-6 text-blue-500" title="Entreprise Vérifiée" />}
|
||||
</div>
|
||||
<div className="text-brand-600 font-medium mt-1 uppercase tracking-wide text-sm">{business.category}</div>
|
||||
{business.isFeatured && (
|
||||
<div className="inline-flex items-center px-2 py-1 rounded bg-yellow-100 text-yellow-800 text-xs font-bold mt-2">
|
||||
<Award className="w-3 h-3 mr-1" /> ENTREPRENEUR DU MOIS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<Share2 className="w-4 h-4 mr-2" /> Partager
|
||||
</button>
|
||||
<a href="#contact-form" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700">
|
||||
Contacter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-6 text-sm text-gray-500 border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
|
||||
{business.location}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Star className="w-4 h-4 mr-1 text-yellow-400 fill-current" />
|
||||
<span className="font-bold text-gray-700 mr-1">{business.rating}</span>
|
||||
({business.viewCount} vues)
|
||||
</div>
|
||||
{business.tags.map(tag => (
|
||||
<span key={tag} className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
|
||||
{/* Left Column: Main Content */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
|
||||
{/* Presentation */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4">À propos</h2>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
{business.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Founder Section (Specifically for Featured or if data exists) */}
|
||||
{(business.founderName || business.founderImageUrl) && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10">
|
||||
<Quote className="w-24 h-24 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6 relative z-10">Le Fondateur</h2>
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-center sm:items-start relative z-10">
|
||||
{business.founderImageUrl && (
|
||||
<img
|
||||
src={business.founderImageUrl}
|
||||
alt={business.founderName}
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-50 shadow-md"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{business.founderName}</h3>
|
||||
<p className="text-brand-600 text-sm font-medium mb-3">CEO & Fondateur</p>
|
||||
<p className="text-gray-600 italic">
|
||||
"Notre mission est d'apporter des solutions concrètes et adaptées aux réalités locales, tout en visant l'excellence internationale."
|
||||
</p>
|
||||
{business.keyMetric && (
|
||||
<div className="mt-4 inline-block bg-brand-50 text-brand-700 px-3 py-1 rounded-full text-xs font-bold">
|
||||
🚀 {business.keyMetric}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{videoEmbedUrl && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-4 flex items-center">
|
||||
<Play className="w-5 h-5 mr-2 text-brand-600" /> Présentation Vidéo
|
||||
</h2>
|
||||
<div className="aspect-w-16 aspect-h-9 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={videoEmbedUrl}
|
||||
title={`Présentation de ${business.name}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="w-full h-64 md:h-96 rounded-lg"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offers */}
|
||||
{businessOffers.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Produits & Services</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{businessOffers.map(offer => (
|
||||
<div key={offer.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div className="h-40 bg-gray-100 relative">
|
||||
<img src={offer.imageUrl} alt={offer.title} className="w-full h-full object-cover" />
|
||||
<span className={`absolute top-2 right-2 px-2 py-1 text-xs font-bold rounded uppercase ${offer.type === OfferType.PRODUCT ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
{offer.type === OfferType.PRODUCT ? 'Produit' : 'Service'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-gray-900 line-clamp-1">{offer.title}</h3>
|
||||
<p className="text-brand-600 font-bold mt-1">
|
||||
{new Intl.NumberFormat('fr-FR').format(offer.price)} {offer.currency}
|
||||
</p>
|
||||
<button className="mt-3 w-full block text-center bg-gray-50 text-gray-700 py-2 rounded text-sm font-medium hover:bg-gray-100 transition-colors">
|
||||
Commander
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sidebar */}
|
||||
<div className="space-y-8">
|
||||
{/* Contact Card */}
|
||||
<div id="contact-form" className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Contact</h3>
|
||||
|
||||
{/* Direct Info */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-sm truncate">{business.contactEmail}</span>
|
||||
</a>
|
||||
{business.contactPhone && (
|
||||
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
|
||||
<Phone className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-sm">{business.contactPhone}</span>
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks?.website && (
|
||||
<a href={business.socialLinks.website} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center mr-3 text-brand-600 flex-shrink-0">
|
||||
<Globe className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-sm truncate">Site Web</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="border-t border-gray-100 pt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Envoyer un message</h4>
|
||||
{formStatus === 'success' ? (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||
Message envoyé avec succès !
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleContactSubmit} className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Votre nom"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={contactForm.name}
|
||||
onChange={(e) => setContactForm({...contactForm, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Votre email"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={contactForm.email}
|
||||
onChange={(e) => setContactForm({...contactForm, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="Votre message..."
|
||||
required
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
|
||||
value={contactForm.message}
|
||||
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formStatus === 'sending'}
|
||||
className="w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-900 hover:bg-gray-800 focus:outline-none transition-colors disabled:opacity-70"
|
||||
>
|
||||
{formStatus === 'sending' ? 'Envoi...' : <><Send className="w-3 h-3 mr-2" /> Envoyer</>}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||
<div className="flex justify-center space-x-6">
|
||||
{business.socialLinks?.facebook && (
|
||||
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#1877F2] transition-colors">
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks?.linkedin && (
|
||||
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#0A66C2] transition-colors">
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.socialLinks?.instagram && (
|
||||
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-[#E4405F] transition-colors">
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar Businesses (Mock) */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Dans la même catégorie</h3>
|
||||
<div className="space-y-4">
|
||||
{MOCK_BUSINESSES
|
||||
.filter(b => b.category === business.category && b.id !== business.id)
|
||||
.slice(0, 3)
|
||||
.map(similar => (
|
||||
<Link key={similar.id} to={`/directory/${similar.id}`} className="flex items-center group">
|
||||
<div className="w-12 h-12 rounded bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img src={similar.logoUrl} alt={similar.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="ml-3 overflow-hidden">
|
||||
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-brand-600 transition-colors">{similar.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{similar.location}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessDetailPage;
|
||||
113
pages/DashboardPage.tsx
Normal file
113
pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LayoutDashboard, Edit3, ShoppingBag, CreditCard, LogOut, Eye } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import { MOCK_BUSINESSES } from '../services/mockData';
|
||||
import DashboardOverview from '../components/dashboard/DashboardOverview';
|
||||
import DashboardProfile from '../components/dashboard/DashboardProfile';
|
||||
import DashboardOffers from '../components/dashboard/DashboardOffers';
|
||||
import PricingSection from '../components/PricingSection';
|
||||
|
||||
const DashboardPage = ({ user, onLogout }: { user: User, onLogout: () => void }) => {
|
||||
const [currentView, setCurrentView] = useState<'overview' | 'profile' | 'offers' | 'subscription'>('overview');
|
||||
const [business, setBusiness] = useState(MOCK_BUSINESSES[0]); // In real app, fetch by user.id
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
{/* 1. Sidebar */}
|
||||
<div className="hidden md:flex md:flex-col md:w-64 md:fixed md:inset-y-0 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
||||
<Link to="/" className="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-lg text-gray-900">Afropreunariat</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">
|
||||
<div className="px-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold shrink-0">
|
||||
{user.name.charAt(0)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-700 truncate">{user.name}</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="h-2 w-2 rounded-full bg-green-400 mr-1"></span>
|
||||
<span className="text-xs text-gray-500">Actif</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-2 flex-1 px-2 space-y-1">
|
||||
<button onClick={() => setCurrentView('overview')} className={`${currentView === 'overview' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||
<LayoutDashboard className={`${currentView === 'overview' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||
Tableau de Bord
|
||||
</button>
|
||||
<button onClick={() => setCurrentView('profile')} className={`${currentView === 'profile' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||
<Edit3 className={`${currentView === 'profile' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||
Éditer mon profil
|
||||
</button>
|
||||
<button onClick={() => setCurrentView('offers')} className={`${currentView === 'offers' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||
<ShoppingBag className={`${currentView === 'offers' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||
Mes Offres
|
||||
</button>
|
||||
<button onClick={() => setCurrentView('subscription')} className={`${currentView === 'subscription' ? 'bg-brand-50 text-brand-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md w-full`}>
|
||||
<CreditCard className={`${currentView === 'subscription' ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 flex-shrink-0 h-5 w-5`} />
|
||||
Abonnement
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
|
||||
<button onClick={onLogout} className="flex-shrink-0 w-full group block text-gray-600 hover:text-red-600 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<LogOut className="inline-block h-5 w-5 mr-2" />
|
||||
<span className="text-sm font-medium">Déconnexion</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content */}
|
||||
<div className="flex-1 flex flex-col md:pl-64 overflow-hidden">
|
||||
<header className="bg-white shadow-sm z-10 flex justify-between items-center px-6 py-4 sticky top-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900 sm:truncate">
|
||||
{currentView === 'overview' && 'Vue d\'ensemble'}
|
||||
{currentView === 'profile' && 'Mon Profil'}
|
||||
{currentView === 'offers' && 'Gestion des Offres'}
|
||||
{currentView === 'subscription' && 'Mon Abonnement'}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a href={`/directory`} target="_blank" rel="noopener noreferrer" className="hidden sm:inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
|
||||
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||
Voir ma fiche
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{currentView === 'overview' && <DashboardOverview business={business} />}
|
||||
{currentView === 'profile' && <DashboardProfile business={business} setBusiness={setBusiness} />}
|
||||
{currentView === 'offers' && <DashboardOffers />}
|
||||
{currentView === 'subscription' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Abonnement Actuel : <span className="text-brand-600 font-bold">Starter (Gratuit)</span></h3>
|
||||
<p className="text-sm text-gray-500">Passez au niveau supérieur pour débloquer plus de fonctionnalités.</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
Actif
|
||||
</span>
|
||||
</div>
|
||||
<PricingSection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
122
pages/DirectoryPage.tsx
Normal file
122
pages/DirectoryPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { MOCK_BUSINESSES } from '../services/mockData';
|
||||
import { CATEGORIES } from '../types';
|
||||
import BusinessCard from '../components/BusinessCard';
|
||||
import DirectoryHero from '../components/DirectoryHero';
|
||||
|
||||
const DirectoryPage = () => {
|
||||
const [filterCategory, setFilterCategory] = useState('All');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const useQuery = () => {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
}
|
||||
const query = useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.get('q');
|
||||
if (q) setSearchQuery(q);
|
||||
}, [query]);
|
||||
|
||||
const featuredBusiness = useMemo(() => {
|
||||
return MOCK_BUSINESSES.find(b => b.isFeatured);
|
||||
}, []);
|
||||
|
||||
const filteredBusinesses = useMemo(() => {
|
||||
return MOCK_BUSINESSES.filter(b => {
|
||||
const matchesCategory = filterCategory === 'All' || b.category === filterCategory;
|
||||
const matchesSearch = b.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [filterCategory, searchQuery]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* New Hero Section */}
|
||||
{featuredBusiness && <DirectoryHero featuredBusiness={featuredBusiness} />}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Filters Sidebar */}
|
||||
<div className="w-full md:w-64 flex-shrink-0">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 sticky top-24">
|
||||
<h3 className="font-bold text-lg mb-4 flex items-center"><Search className="w-4 h-4 mr-2"/> Filtres</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-brand-500 focus:border-brand-500 sm:text-sm p-2 border"
|
||||
placeholder="Nom, mot-clé..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Catégorie</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="cat-all"
|
||||
name="category"
|
||||
type="radio"
|
||||
checked={filterCategory === 'All'}
|
||||
onChange={() => setFilterCategory('All')}
|
||||
className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="cat-all" className="ml-3 text-sm text-gray-600">Toutes</label>
|
||||
</div>
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat} className="flex items-center">
|
||||
<input
|
||||
id={`cat-${cat}`}
|
||||
name="category"
|
||||
type="radio"
|
||||
checked={filterCategory === cat}
|
||||
onChange={() => setFilterCategory(cat)}
|
||||
className="focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300"
|
||||
/>
|
||||
<label htmlFor={`cat-${cat}`} className="ml-3 text-sm text-gray-600 truncate" title={cat}>{cat}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
<div className="flex-1">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold font-serif text-gray-900">Annuaire des entreprises</h1>
|
||||
<span className="text-sm text-gray-500">{filteredBusinesses.length} résultats</span>
|
||||
</div>
|
||||
|
||||
{filteredBusinesses.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredBusinesses.map(biz => (
|
||||
<BusinessCard key={biz.id} business={biz} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-white rounded-xl border border-gray-100 border-dashed">
|
||||
<div className="mx-auto h-12 w-12 text-gray-400">
|
||||
<Search className="h-12 w-12" />
|
||||
</div>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun résultat</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Essayez d'ajuster vos filtres de recherche.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryPage;
|
||||
95
pages/HomePage.tsx
Normal file
95
pages/HomePage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Search, MapPin, Briefcase, TrendingUp } from 'lucide-react';
|
||||
import { CATEGORIES } from '../types';
|
||||
import { MOCK_BUSINESSES } from '../services/mockData';
|
||||
import BusinessCard from '../components/BusinessCard';
|
||||
|
||||
const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
navigate(`/directory?q=${searchTerm}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-dark-900 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-40">
|
||||
{/* UPDATED IMAGE: Group of diverse/African professionals */}
|
||||
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" />
|
||||
</div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
||||
Boostez votre visibilité dans <br/>
|
||||
<span className="text-brand-500">l'écosystème africain</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
|
||||
L'annuaire 2.0 qui connecte les talents, les entrepreneurs et les entreprises de la diaspora et du continent.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="max-w-3xl bg-white p-2 rounded-lg shadow-xl flex flex-col md:flex-row gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Que recherchez-vous ? (ex: Développeur, Traiteur...)"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
||||
<MapPin className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
|
||||
<input type="text" placeholder="Localisation (ex: Abidjan)" className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900" />
|
||||
</div>
|
||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Categories */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 font-serif">Secteurs en vedette</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{CATEGORIES.slice(0, 4).map((cat, idx) => (
|
||||
<div key={idx} className="group cursor-pointer bg-white p-6 rounded-xl border border-gray-100 shadow-sm hover:shadow-md hover:border-brand-200 transition-all text-center">
|
||||
<div className="w-12 h-12 bg-brand-50 text-brand-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-brand-600 group-hover:text-white transition-colors">
|
||||
<Briefcase className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">{cat}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Businesses */}
|
||||
<div className="bg-gray-50 py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-serif">Entreprises à la une</h2>
|
||||
<p className="text-gray-500 mt-2">Découvrez les pépites de notre communauté.</p>
|
||||
</div>
|
||||
<Link to="/directory" className="text-brand-600 font-medium hover:text-brand-700 flex items-center">
|
||||
Voir tout l'annuaire <TrendingUp className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{MOCK_BUSINESSES.map(biz => (
|
||||
<BusinessCard key={biz.id} business={biz} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
110
pages/InterviewDetailPage.tsx
Normal file
110
pages/InterviewDetailPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Share2, Play, Calendar, User, Building2 } from 'lucide-react';
|
||||
import { MOCK_INTERVIEWS } from '../services/mockData';
|
||||
import { InterviewType } from '../types';
|
||||
|
||||
const InterviewDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const interview = MOCK_INTERVIEWS.find(i => i.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
if (!interview) return null;
|
||||
|
||||
// Helper to get YouTube embed URL
|
||||
const getEmbedUrl = (url: string) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
|
||||
};
|
||||
|
||||
const isVideo = interview.type === InterviewType.VIDEO;
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link to="/afrolife" className="inline-flex items-center text-gray-500 hover:text-brand-600 mb-8 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour à Afro Life
|
||||
</Link>
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4">
|
||||
{isVideo ? 'Interview Vidéo' : 'Grand Entretien'}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-serif font-bold text-gray-900 mb-6 leading-tight">
|
||||
{interview.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 mr-2 text-brand-500" />
|
||||
<span className="font-medium text-gray-900">{interview.guestName}</span>
|
||||
<span className="mx-1 text-gray-400">|</span>
|
||||
<span>{interview.role}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Building2 className="w-4 h-4 mr-2 text-brand-500" />
|
||||
{interview.companyName}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2 text-brand-500" />
|
||||
{interview.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
{isVideo ? (
|
||||
<div className="bg-black rounded-xl overflow-hidden shadow-2xl aspect-w-16 aspect-h-9 mb-10">
|
||||
{interview.videoUrl ? (
|
||||
<iframe
|
||||
src={getEmbedUrl(interview.videoUrl) || ''}
|
||||
title={interview.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
></iframe>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
||||
<p className="text-white">Vidéo non disponible</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10">
|
||||
<img src={interview.thumbnailUrl} alt={interview.title} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description / Article Content */}
|
||||
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
|
||||
{!isVideo && interview.content ? (
|
||||
interview.content.split('\n').map((paragraph, idx) => (
|
||||
<p key={idx}>{paragraph}</p>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
|
||||
{interview.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-center">
|
||||
<button className="flex items-center px-6 py-3 rounded-full bg-brand-50 text-brand-700 font-medium hover:bg-brand-100 transition-colors">
|
||||
<Share2 className="w-5 h-5 mr-2" />
|
||||
Partager cette interview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InterviewDetailPage;
|
||||
64
pages/LoginPage.tsx
Normal file
64
pages/LoginPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const LoginPage = ({ onLogin }: { onLogin: () => void }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Mock login
|
||||
onLogin();
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">A</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Connexion à votre espace</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Ou <a href="#" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1€</a>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Adresse email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-brand-500 focus:border-brand-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Mot de passe"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500">
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-center text-gray-500">
|
||||
(Compte démo : n'importe quel email/mdp fonctionne)
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
36
pages/SubscriptionPage.tsx
Normal file
36
pages/SubscriptionPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import React from 'react';
|
||||
import PricingSection from '../components/PricingSection';
|
||||
|
||||
const SubscriptionPage = () => {
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="bg-dark-900 pt-20 pb-10 text-center">
|
||||
<h1 className="text-3xl font-bold text-white font-serif">Nos Offres</h1>
|
||||
<p className="text-gray-400 mt-2">Rejoignez la plus grande communauté d'entrepreneurs.</p>
|
||||
</div>
|
||||
<PricingSection />
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-16">
|
||||
<h2 className="text-2xl font-bold text-gray-900 text-center mb-10">Questions Fréquentes</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 className="font-bold text-gray-900 mb-2">Puis-je changer d'abonnement plus tard ?</h3>
|
||||
<p className="text-gray-600">Oui, vous pouvez passer d'un plan à l'autre à tout moment depuis votre tableau de bord. La différence sera calculée au prorata.</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 className="font-bold text-gray-900 mb-2">Quels moyens de paiement acceptez-vous ?</h3>
|
||||
<p className="text-gray-600">Nous acceptons les cartes bancaires (Visa, Mastercard) ainsi que tous les principaux Mobile Money (Orange Money, MTN, Wave, Moov) disponibles en Afrique de l'Ouest et Centrale.</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 className="font-bold text-gray-900 mb-2">Comment fonctionne le badge "Vérifié" ?</h3>
|
||||
<p className="text-gray-600">Une fois abonné au plan Booster ou Empire, notre équipe effectuera une vérification rapide de votre entreprise (identité légale) sous 48h pour activer votre badge.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPage;
|
||||
57
services/geminiService.ts
Normal file
57
services/geminiService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
// Initialize Gemini
|
||||
// Note: In a real production app, backend proxy is preferred for key safety.
|
||||
// For this demo, we assume environment variable usage as per instructions.
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
export const generateBusinessDescription = async (name: string, category: string, keywords: string): Promise<string> => {
|
||||
try {
|
||||
const model = 'gemini-2.5-flash';
|
||||
const prompt = `
|
||||
Tu es un expert en copywriting marketing pour Afropreunariat.
|
||||
Rédige une description professionnelle, attrayante et optimisée SEO pour une entreprise.
|
||||
|
||||
Nom de l'entreprise : ${name}
|
||||
Secteur : ${category}
|
||||
Mots-clés/Services : ${keywords}
|
||||
|
||||
La description doit faire environ 80-100 mots, être en français, inspirer confiance et professionnalisme.
|
||||
Ne mets pas de guillemets au début ou à la fin.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model,
|
||||
contents: prompt,
|
||||
});
|
||||
|
||||
return response.text || "Impossible de générer une description pour le moment.";
|
||||
} catch (error) {
|
||||
console.error("Gemini API Error:", error);
|
||||
return "Erreur lors de la génération. Veuillez rédiger votre description manuellement.";
|
||||
}
|
||||
};
|
||||
|
||||
export const generateBusinessIdeas = async (category: string): Promise<string[]> => {
|
||||
try {
|
||||
const model = 'gemini-2.5-flash';
|
||||
const prompt = `Donne-moi 3 idées de slogans courts et percutants pour une entreprise dans le secteur : ${category}. Retourne uniquement une liste JSON de chaînes de caractères.`;
|
||||
|
||||
// For simplicity in this demo, we ask for text and split lines,
|
||||
// but JSON schema is better for robust apps.
|
||||
const response = await ai.models.generateContent({
|
||||
model,
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const json = JSON.parse(response.text || "[]");
|
||||
if (Array.isArray(json)) return json;
|
||||
return ["L'excellence au service de l'Afrique", "Innover pour demain", "Votre partenaire de confiance"];
|
||||
|
||||
} catch (error) {
|
||||
return ["Votre slogan ici"];
|
||||
}
|
||||
};
|
||||
352
services/mockData.ts
Normal file
352
services/mockData.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
|
||||
import { Business, BlogPost, User, UserRole, Offer, OfferType, Interview, InterviewType } from '../types';
|
||||
|
||||
export const MOCK_USER: User = {
|
||||
id: 'u1',
|
||||
name: 'Jean-Marc Kouassi',
|
||||
email: 'jm.kouassi@example.com',
|
||||
role: UserRole.ENTREPRENEUR,
|
||||
avatar: 'https://i.pravatar.cc/150?u=a042581f4e29026024d'
|
||||
};
|
||||
|
||||
export const MOCK_BUSINESSES: Business[] = [
|
||||
{
|
||||
id: '1',
|
||||
ownerId: 'u1',
|
||||
name: 'AfroTech Solutions',
|
||||
category: 'Technologie & IT',
|
||||
location: 'Abidjan, Côte d\'Ivoire',
|
||||
description: 'Leader dans le développement de solutions logicielles sur mesure pour les PME ouest-africaines. Nous accompagnons la transformation digitale des entreprises locales avec des outils ERP et CRM adaptés aux réalités du terrain (faible connectivité, mobile money).',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=1',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=ysz5S6P_ks0',
|
||||
socialLinks: {
|
||||
website: 'https://afrotech.ci',
|
||||
linkedin: 'https://linkedin.com',
|
||||
facebook: 'https://facebook.com'
|
||||
},
|
||||
contactEmail: 'contact@afrotech.ci',
|
||||
contactPhone: '+225 07 07 07 07 07',
|
||||
verified: true,
|
||||
viewCount: 1240,
|
||||
rating: 4.8,
|
||||
tags: ['Dev', 'SaaS', 'Mobile', 'ERP'],
|
||||
// Featured Data
|
||||
isFeatured: true,
|
||||
founderName: 'Jean-Marc Kouassi',
|
||||
// UPDATED IMAGE: Profile of an Afro-descendant entrepreneur
|
||||
founderImageUrl: 'https://images.unsplash.com/photo-1531384441138-2736e62e0919?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||
keyMetric: '+50 Projets Livrés'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
ownerId: 'u2',
|
||||
name: 'Baobab Foods',
|
||||
category: 'Restauration & Alimentation',
|
||||
location: 'Paris, France',
|
||||
description: 'Traiteur événementiel haut de gamme spécialisé dans la gastronomie panafricaine revisitée. Nous apportons une touche d\'élégance et de modernité aux saveurs ancestrales pour vos mariages, séminaires et événements d\'entreprise.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=2',
|
||||
contactEmail: 'hello@baobabfoods.fr',
|
||||
contactPhone: '+33 6 12 34 56 78',
|
||||
verified: true,
|
||||
viewCount: 850,
|
||||
rating: 4.9,
|
||||
tags: ['Traiteur', 'Bio', 'Événementiel', 'Gastronomie'],
|
||||
founderName: 'Aïssa Maïga',
|
||||
socialLinks: { instagram: 'https://instagram.com' }
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
ownerId: 'u3',
|
||||
name: 'Lagos Fashion House',
|
||||
category: 'Mode & Textile',
|
||||
location: 'Lagos, Nigeria',
|
||||
description: 'Création de tenues modernes utilisant le tissu Ankara authentique. Nous travaillons directement avec des tisserands locaux pour garantir une qualité exceptionnelle et une rémunération équitable. Exportation internationale et vente en ligne.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=3',
|
||||
contactEmail: 'sales@lagosfashion.ng',
|
||||
verified: false,
|
||||
viewCount: 320,
|
||||
rating: 4.5,
|
||||
tags: ['Mode', 'Ankara', 'Luxe', 'Ethique'],
|
||||
founderName: 'Chidinma Okeke'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
ownerId: 'u4',
|
||||
name: 'Sahara Solar',
|
||||
category: 'Construction & BTP',
|
||||
location: 'Dakar, Sénégal',
|
||||
description: 'Installation de panneaux solaires pour les zones rurales et urbaines. L\'énergie propre accessible à tous. Nous proposons des kits solaires domestiques payables en plusieurs fois via mobile money.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=4',
|
||||
contactEmail: 'info@saharasolar.sn',
|
||||
contactPhone: '+221 77 000 00 00',
|
||||
verified: true,
|
||||
viewCount: 560,
|
||||
rating: 4.7,
|
||||
tags: ['Énergie', 'Solaire', 'Durable', 'Tech'],
|
||||
founderName: 'Ousmane Diop'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
ownerId: 'u5',
|
||||
name: 'Nubian Essence',
|
||||
category: 'Cosmétique & Beauté',
|
||||
location: 'Douala, Cameroun',
|
||||
description: 'Une gamme de soins de la peau 100% naturels à base de beurre de karité, de cacao et d\'huiles essentielles locales. Nos produits sont sans parabènes et non testés sur les animaux.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=5',
|
||||
contactEmail: 'contact@nubianessence.cm',
|
||||
contactPhone: '+237 6 99 99 99 99',
|
||||
socialLinks: { instagram: 'https://instagram.com', facebook: 'https://facebook.com' },
|
||||
verified: true,
|
||||
viewCount: 410,
|
||||
rating: 4.6,
|
||||
tags: ['Bio', 'Skincare', 'Karité', 'Naturel'],
|
||||
founderName: 'Marie-Claire Etoa',
|
||||
founderImageUrl: 'https://images.unsplash.com/photo-1589156191108-c762ff4b96ab?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
ownerId: 'u6',
|
||||
name: 'Kijani Academy',
|
||||
category: 'Éducation & Formation',
|
||||
location: 'Nairobi, Kenya',
|
||||
description: 'L\'école de code qui forme la prochaine génération de développeurs africains. Bootcamp intensif de 6 mois en JavaScript, Python et React. Partenariats d\'embauche avec les plus grandes Tech Companies.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=6',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=FpMNs7H24X0',
|
||||
contactEmail: 'admissions@kijani.ke',
|
||||
contactPhone: '+254 700 000 000',
|
||||
socialLinks: { linkedin: 'https://linkedin.com', website: 'https://kijani.ke' },
|
||||
verified: true,
|
||||
viewCount: 980,
|
||||
rating: 5.0,
|
||||
tags: ['Formation', 'Code', 'Bootcamp', 'Emploi'],
|
||||
founderName: 'David Ochieng',
|
||||
keyMetric: '90% d\'embauche'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
ownerId: 'u7',
|
||||
name: 'PayNa',
|
||||
category: 'Services aux entreprises',
|
||||
location: 'Kinshasa, RDC',
|
||||
description: 'La solution de paiement agrégée pour les e-commerçants en RDC. Acceptez M-Pesa, Orange Money, Airtel Money et les cartes Visa/Mastercard via une API unique et sécurisée.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=7',
|
||||
contactEmail: 'biz@payna.cd',
|
||||
verified: false,
|
||||
viewCount: 230,
|
||||
rating: 4.2,
|
||||
tags: ['FinTech', 'Paiement', 'Mobile Money', 'API'],
|
||||
founderName: 'Serge Mbemba'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
ownerId: 'u8',
|
||||
name: 'EcoBati Africa',
|
||||
category: 'Construction & BTP',
|
||||
location: 'Cotonou, Bénin',
|
||||
description: 'Cabinet d\'architecture écologique spécialisé dans l\'utilisation de matériaux géo-sourcés (briques de terre compressée, bambou). Nous concevons des bâtiments modernes, frais et durables.',
|
||||
logoUrl: 'https://picsum.photos/200/200?random=8',
|
||||
contactEmail: 'projet@ecobati.bj',
|
||||
contactPhone: '+229 97 00 00 00',
|
||||
socialLinks: { instagram: 'https://instagram.com', website: 'https://ecobati.bj' },
|
||||
verified: true,
|
||||
viewCount: 670,
|
||||
rating: 4.8,
|
||||
tags: ['Architecture', 'Écologie', 'BTC', 'Durable'],
|
||||
founderName: 'Fatimata Sylla',
|
||||
founderImageUrl: 'https://images.unsplash.com/photo-1542596594-649edbc13630?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80'
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_OFFERS: Offer[] = [
|
||||
// AfroTech Offers
|
||||
{
|
||||
id: 'o1',
|
||||
businessId: '1',
|
||||
title: 'Audit Digital Gratuit',
|
||||
type: OfferType.SERVICE,
|
||||
price: 0,
|
||||
currency: 'XOF',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=20',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: 'o2',
|
||||
businessId: '1',
|
||||
title: 'Pack Site Vitrine',
|
||||
type: OfferType.SERVICE,
|
||||
price: 150000,
|
||||
currency: 'XOF',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=21',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: 'o3',
|
||||
businessId: '1',
|
||||
title: 'Logiciel Gestion de Stock',
|
||||
type: OfferType.PRODUCT,
|
||||
price: 35000,
|
||||
currency: 'XOF',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=22',
|
||||
active: true
|
||||
},
|
||||
// Nubian Essence Offers
|
||||
{
|
||||
id: 'o4',
|
||||
businessId: '5',
|
||||
title: 'Coffret Rituel Karité',
|
||||
type: OfferType.PRODUCT,
|
||||
price: 25000,
|
||||
currency: 'XOF',
|
||||
description: 'Beurre corporel, savon noir et huile hydratante.',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=23',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: 'o5',
|
||||
businessId: '5',
|
||||
title: 'Savon Noir Liquide',
|
||||
type: OfferType.PRODUCT,
|
||||
price: 5000,
|
||||
currency: 'XOF',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=24',
|
||||
active: true
|
||||
},
|
||||
// Kijani Academy Offers
|
||||
{
|
||||
id: 'o6',
|
||||
businessId: '6',
|
||||
title: 'Bootcamp Fullstack (6 mois)',
|
||||
type: OfferType.SERVICE,
|
||||
price: 1500,
|
||||
currency: 'EUR',
|
||||
description: 'Formation intensive pour devenir développeur web. Paiement échelonné possible.',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=25',
|
||||
active: true
|
||||
},
|
||||
// EcoBati Offers
|
||||
{
|
||||
id: 'o7',
|
||||
businessId: '8',
|
||||
title: 'Étude de faisabilité',
|
||||
type: OfferType.SERVICE,
|
||||
price: 100000,
|
||||
currency: 'XOF',
|
||||
description: 'Analyse de terrain et propositions de matériaux écologiques.',
|
||||
imageUrl: 'https://picsum.photos/300/200?random=26',
|
||||
active: true
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_BLOG_POSTS: BlogPost[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Comment financer sa start-up en Afrique en 2025 ?',
|
||||
excerpt: 'Analyse des nouveaux fonds d\'investissement et des opportunités pour les jeunes entrepreneurs.',
|
||||
content: `Le financement des startups en Afrique connaît une véritable révolution. En 2025, de nouveaux acteurs entrent en jeu, diversifiant les sources de capitaux au-delà du capital-risque traditionnel.
|
||||
|
||||
1. Les Business Angels Locaux
|
||||
De plus en plus d'entrepreneurs à succès réinvestissent dans l'écosystème local. Ces "afro-capitalistes" apportent non seulement des fonds, mais aussi un mentorat précieux et un réseau local indispensable.
|
||||
|
||||
2. Le Crowdfunding (Financement participatif)
|
||||
Les plateformes de financement participatif dédiées à l'Afrique permettent de mobiliser la diaspora. C'est un excellent moyen de valider son marché tout en levant des fonds d'amorçage sans diluer son capital trop tôt.
|
||||
|
||||
3. Les Fonds d'Impact
|
||||
Les investisseurs internationaux cherchent désormais la rentabilité couplée à l'impact social. Si votre projet résout un problème structurel (éducation, santé, logistique), vous avez toutes vos chances.
|
||||
|
||||
En conclusion, la clé en 2025 n'est plus seulement d'avoir une bonne idée, mais de démontrer une capacité d'exécution rapide et une compréhension fine des spécificités locales.`,
|
||||
author: 'Sarah Ndiaye',
|
||||
date: '12 Oct 2024',
|
||||
imageUrl: 'https://picsum.photos/800/400?random=10'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Digitalisation des TPE : Les outils indispensables',
|
||||
excerpt: 'De la comptabilité au marketing, voici la stack technique idéale pour débuter à moindre coût.',
|
||||
content: `La transformation numérique n'est pas réservée aux grandes entreprises. Pour une TPE africaine, bien choisir ses outils peut faire la différence entre stagnation et croissance exponentielle.
|
||||
|
||||
Voici notre sélection d'outils incontournables et souvent gratuits :
|
||||
|
||||
- Gestion de projet : Trello ou Asana pour organiser les tâches de l'équipe sans usine à gaz.
|
||||
- Communication : WhatsApp Business est incontournable en Afrique pour la relation client, bien plus que l'email dans certains secteurs.
|
||||
- Design : Canva permet de créer des visuels professionnels pour vos réseaux sociaux sans compétences graphiques.
|
||||
- Paiement : Intégrez des solutions d'agrégation de paiement mobile (Mobile Money) dès le premier jour. C'est là que se trouve l'argent de vos clients.
|
||||
|
||||
Ne cherchez pas à tout automatiser dès le début. Commencez par digitaliser ce qui vous prend le plus de temps et qui a le moins de valeur ajoutée.`,
|
||||
author: 'Admin Afropreunariat',
|
||||
date: '05 Oct 2024',
|
||||
imageUrl: 'https://picsum.photos/800/400?random=11'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Diaspora et retour au pays : Entreprendre le grand saut',
|
||||
excerpt: 'Témoignages d\'entrepreneurs qui ont quitté l\'Europe pour lancer leur activité sur le continent.',
|
||||
content: `Le phénomène des "Repats" s'accélère. Quitter une situation stable à Paris, Londres ou Montréal pour entreprendre à Abidjan, Dakar ou Nairobi est un défi autant personnel que professionnel.
|
||||
|
||||
Le choc culturel inversé
|
||||
Beaucoup sous-estiment le temps de réadaptation. "Je pensais connaître mon pays, mais j'y venais en vacances. Y faire du business est totalement différent", nous confie Marc, fondateur d'une startup logistique.
|
||||
|
||||
Les opportunités
|
||||
L'avantage de la diaspora est la double culture. La capacité à faire le pont entre les standards internationaux et les réalités locales est un atout concurrentiel majeur.
|
||||
|
||||
Conseil clé : Ne venez pas en "donneur de leçons". Venez avec humilité, écoutez le marché local, et adaptez vos processus. Ce qui marche en Europe ne marche pas forcément tel quel en Afrique.`,
|
||||
author: 'Marc Dubois',
|
||||
date: '28 Sept 2024',
|
||||
imageUrl: 'https://picsum.photos/800/400?random=12'
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_INTERVIEWS: Interview[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
title: 'De la Silicone Valley à Lagos : Itinéraire d\'un génie du code',
|
||||
guestName: 'Tunde Onakoya',
|
||||
companyName: 'Lagos Code Academy',
|
||||
role: 'Fondateur',
|
||||
type: InterviewType.VIDEO,
|
||||
thumbnailUrl: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', // Placeholder
|
||||
excerpt: 'Tunde nous raconte comment il a quitté un poste prestigieux chez Google pour former la prochaine génération de développeurs nigérians.',
|
||||
date: '15 Oct 2024',
|
||||
duration: '24 min'
|
||||
},
|
||||
{
|
||||
id: 'i2',
|
||||
title: 'La reine du Karité : Bâtir un empire cosmétique bio',
|
||||
guestName: 'Amina Diop',
|
||||
companyName: 'Shea Gold',
|
||||
role: 'CEO',
|
||||
type: InterviewType.ARTICLE,
|
||||
thumbnailUrl: 'https://images.unsplash.com/photo-1571781926291-280584795465?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||
content: `
|
||||
C'est dans son village natal du nord de la Côte d'Ivoire qu'Amina Diop a puisé son inspiration. Aujourd'hui, sa marque Shea Gold s'exporte dans 15 pays.
|
||||
|
||||
**Journaliste : Comment est née l'idée de Shea Gold ?**
|
||||
|
||||
Amina : Je voyais ma grand-mère produire ce beurre de karité d'une qualité exceptionnelle, mais qui était vendu à vil prix sur les marchés locaux. J'ai réalisé qu'il y avait un décalage entre la valeur du produit et sa valorisation commerciale.
|
||||
|
||||
**J : Quels ont été les principaux défis logistiques ?**
|
||||
|
||||
Amina : L'exportation est un parcours du combattant. Les normes phytosanitaires européennes sont drastiques. Il nous a fallu deux ans pour certifier toute notre chaîne de production. Mais aujourd'hui, cette rigueur est notre force.
|
||||
|
||||
**J : Un conseil pour les entrepreneuses ?**
|
||||
|
||||
Amina : N'ayez pas peur de commencer petit. J'ai commencé avec 50 pots vendus à mes collègues de bureau. Testez, apprenez, et grandissez organiquement.
|
||||
`,
|
||||
excerpt: 'Découvrez comment une recette ancestrale est devenue une marque de luxe internationale.',
|
||||
date: '02 Oct 2024',
|
||||
duration: '8 min de lecture'
|
||||
},
|
||||
{
|
||||
id: 'i3',
|
||||
title: 'AgriTech : Nourrir l\'Afrique grâce à l\'IA',
|
||||
guestName: 'Kwame Mensah',
|
||||
companyName: 'GreenFields',
|
||||
role: 'Co-fondateur',
|
||||
type: InterviewType.VIDEO,
|
||||
thumbnailUrl: 'https://images.unsplash.com/photo-1595841055318-943e1bf83383?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=placeholder',
|
||||
excerpt: 'Kwame explique comment ses drones analysent les sols pour optimiser les récoltes sans produits chimiques.',
|
||||
date: '20 Sept 2024',
|
||||
duration: '18 min'
|
||||
}
|
||||
];
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
103
types.ts
Normal file
103
types.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
export enum UserRole {
|
||||
VISITOR = 'VISITOR',
|
||||
ENTREPRENEUR = 'ENTREPRENEUR',
|
||||
ADMIN = 'ADMIN'
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface SocialLinks {
|
||||
facebook?: string;
|
||||
linkedin?: string;
|
||||
instagram?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface Business {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
location: string; // City, Country
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
videoUrl?: string; // YouTube/Vimeo link
|
||||
socialLinks?: SocialLinks;
|
||||
contactEmail: string;
|
||||
contactPhone?: string;
|
||||
verified: boolean;
|
||||
viewCount: number;
|
||||
rating: number;
|
||||
tags: string[];
|
||||
|
||||
// New fields for "Entrepreneur of the Month"
|
||||
isFeatured?: boolean;
|
||||
founderName?: string;
|
||||
founderImageUrl?: string;
|
||||
keyMetric?: string; // "3000 clients delivered"
|
||||
}
|
||||
|
||||
export enum OfferType {
|
||||
PRODUCT = 'PRODUCT',
|
||||
SERVICE = 'SERVICE'
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
type: OfferType;
|
||||
price: number;
|
||||
currency: 'EUR' | 'XOF';
|
||||
description?: string;
|
||||
imageUrl: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
date: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export enum InterviewType {
|
||||
VIDEO = 'VIDEO',
|
||||
ARTICLE = 'ARTICLE'
|
||||
}
|
||||
|
||||
export interface Interview {
|
||||
id: string;
|
||||
title: string;
|
||||
guestName: string;
|
||||
companyName: string;
|
||||
role: string;
|
||||
type: InterviewType;
|
||||
thumbnailUrl: string;
|
||||
videoUrl?: string;
|
||||
content?: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
duration?: string; // e.g. "15 min" or "5 min de lecture"
|
||||
}
|
||||
|
||||
export const CATEGORIES = [
|
||||
"Technologie & IT",
|
||||
"Agriculture & Agrobusiness",
|
||||
"Mode & Textile",
|
||||
"Cosmétique & Beauté",
|
||||
"Services aux entreprises",
|
||||
"Restauration & Alimentation",
|
||||
"Construction & BTP",
|
||||
"Éducation & Formation"
|
||||
];
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user