Compare commits
25 Commits
bac36efb94
...
371ed17867
| Author | SHA1 | Date | |
|---|---|---|---|
| 371ed17867 | |||
| f6e3c1bb8c | |||
| 6acd5ebe2e | |||
| 6a42c52b58 | |||
| 72c24e0cc6 | |||
| 67df2d95e9 | |||
| 4aedcdb45a | |||
| d1b32cd401 | |||
| 2148f7281e | |||
| 7b015cb758 | |||
| bb464cf1c5 | |||
| 19b405d9f2 | |||
| a37b34f758 | |||
| 13c88dd4f7 | |||
| 52a9d47c30 | |||
| f93d70c447 | |||
| c73763be8a | |||
| 268e14f369 | |||
| e1aa0f076a | |||
| 3d7cb5f4d1 | |||
| 2d9d707251 | |||
| 5720661f8d | |||
| 1b6d409319 | |||
| 63b5af4868 | |||
| a23c3bf780 |
4
.env
4
.env
@@ -3,4 +3,6 @@
|
|||||||
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
# Exemple avec Supabase ou Neon (si base distante)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
|
# Environnement : "development" = accents rouges, "production" = accents normaux
|
||||||
|
NEXT_PUBLIC_APP_ENV=development
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Build and Push App
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# FORCE l'image ici pour être sûr d'avoir l'outil Docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.TOKEN }}" | docker login git.home.arrondeau.fr -u ${{ gitea.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Build and Push Docker Images
|
|
||||||
run: |
|
|
||||||
# Build and Push FRONT
|
|
||||||
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}:latest .
|
|
||||||
docker push git.home.arrondeau.fr/${{ gitea.repository }}:latest
|
|
||||||
|
|
||||||
# Build and Push ADMIN
|
|
||||||
docker build -t git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest ./admin
|
|
||||||
docker push git.home.arrondeau.fr/${{ gitea.repository }}-admin:latest
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@prisma/client": "6.19.3",
|
"@prisma/client": "6.19.3",
|
||||||
"@prisma/config": "6.19.3",
|
"@prisma/config": "6.19.3",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"afrohub": "file:..",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, settings } = useUser();
|
const { user, settings } = useUser();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [locationTerm, setLocationTerm] = useState('');
|
||||||
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
const [featuredBusinesses, setFeaturedBusinesses] = useState<Business[]>(initialFeatured);
|
||||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||||
const [categories, setCategories] = useState<any[]>(initialCategories);
|
const [categories, setCategories] = useState<any[]>(initialCategories);
|
||||||
@@ -27,7 +28,10 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.push(`/annuaire?q=${searchTerm}`);
|
const params = new URLSearchParams();
|
||||||
|
if (searchTerm) params.append('q', searchTerm);
|
||||||
|
if (locationTerm) params.append('location', locationTerm);
|
||||||
|
router.push(`/annuaire?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +63,13 @@ const HomeClient = ({ initialFeatured, initialPosts, initialCategories }: Props)
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:w-1/3 relative border-t md:border-t-0 md:border-l border-gray-200">
|
<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" />
|
<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" />
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Localisation (ex: Abidjan)"
|
||||||
|
className="w-full pl-10 pr-4 py-3 rounded-md focus:outline-none text-gray-900"
|
||||||
|
value={locationTerm}
|
||||||
|
onChange={(e) => setLocationTerm(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
<button type="submit" className="bg-brand-600 text-white px-8 py-3 rounded-md font-semibold hover:bg-brand-700 transition-colors">
|
||||||
Rechercher
|
Rechercher
|
||||||
|
|||||||
28
app/actions/upload.ts
Normal file
28
app/actions/upload.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert file to buffer
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
// Convert to Base64
|
||||||
|
const base64 = buffer.toString('base64');
|
||||||
|
const mimeType = file.type || 'image/jpeg';
|
||||||
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
|
// Return the data URL directly (it will be saved in the database)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: dataUrl
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during image conversion:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -439,9 +439,206 @@ const BusinessDetailClient = ({ initialBusiness }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Avis et Commentaires */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||||
|
<h2 className="text-xl font-bold font-serif text-gray-900 flex items-center">
|
||||||
|
<Star className="w-5 h-5 mr-2 text-yellow-400 fill-current" /> Avis et Commentaires
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(null)}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
className="transition-transform active:scale-125 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-8 h-8 ${star <= (hoverRating || userRating || 0) ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 font-medium italic">
|
||||||
|
{userRating ? "Merci pour votre note !" : "Cliquez sur une étoile pour voter"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="mb-10 bg-gray-50 rounded-xl p-5 border border-gray-100">
|
||||||
|
<p className="text-sm font-bold text-gray-700 mb-3">Laissez un commentaire</p>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={reviewComment}
|
||||||
|
onChange={(e) => setReviewComment(e.target.value)}
|
||||||
|
placeholder="Que pensez-vous de cette entreprise ? (Qualité, service...)"
|
||||||
|
className="w-full bg-white border border-gray-200 rounded-lg p-4 text-sm focus:ring-2 focus:ring-brand-500 outline-none h-24 resize-none shadow-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRate(userRating || 5)}
|
||||||
|
disabled={isRating}
|
||||||
|
className="absolute bottom-3 right-3 bg-brand-600 text-white px-4 py-1.5 rounded-md text-xs font-bold hover:bg-brand-700 transition-all flex items-center gap-2 shadow-md"
|
||||||
|
>
|
||||||
|
{isRating ? <Loader2 className="w-3 h-3 animate-spin" /> : "Publier"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loadingRatings ? (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : ratings.length > 0 ? (
|
||||||
|
ratings.map((rating) => (
|
||||||
|
<div key={rating.id} className="border-b border-gray-50 last:border-0 pb-6">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-700 font-bold text-sm">
|
||||||
|
{(rating as any).user?.name?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900 text-sm">{(rating as any).user?.name}</p>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-3 h-3 ${star <= rating.value ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-gray-400 ml-2 font-medium">
|
||||||
|
{new Date(rating.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed italic ml-13">
|
||||||
|
<Quote className="w-3 h-3 text-brand-200 inline mr-1" />
|
||||||
|
{rating.comment || "Pas de commentaire"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Reply section for owner */}
|
||||||
|
{isOwner && !rating.reply && (
|
||||||
|
<div className="mt-4 ml-13">
|
||||||
|
{replyingTo === rating.id ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Votre réponse..."
|
||||||
|
className="w-full bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm outline-none focus:ring-1 focus:ring-brand-500 h-20"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleReply(rating.id)} disabled={isSubmittingReply} className="bg-brand-600 text-white px-3 py-1.5 rounded text-xs font-bold">Répondre</button>
|
||||||
|
<button onClick={() => setReplyingTo(null)} className="text-gray-500 text-xs">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setReplyingTo(rating.id)} className="text-brand-600 text-xs font-bold hover:underline">Répondre au client</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rating.reply && (
|
||||||
|
<div className="mt-4 ml-13 bg-brand-50/50 p-4 rounded-lg border-l-2 border-brand-300">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MessageCircle className="w-3 h-3 text-brand-600" />
|
||||||
|
<span className="text-[10px] font-bold text-brand-700 uppercase">Réponse de {business.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 text-sm italic">{rating.reply}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-10 bg-gray-50 rounded-xl border border-dashed border-gray-200">
|
||||||
|
<Star className="w-10 h-10 text-gray-200 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 text-sm">Soyez le premier à donner votre avis !</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
<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">Coordonnées</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{business.websiteUrl && (
|
||||||
|
<a href={business.websiteUrl.startsWith('http') ? business.websiteUrl : `https://${business.websiteUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Globe className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Site Web</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.websiteUrl.replace(/^https?:\/\//, '')}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showEmail && business.contactEmail && (
|
||||||
|
<a href={`mailto:${business.contactEmail}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Mail className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Email</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.contactEmail}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showPhone && business.contactPhone && (
|
||||||
|
<a href={`tel:${business.contactPhone}`} className="flex items-center text-gray-600 hover:text-brand-600 transition-colors group">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center mr-3 group-hover:bg-brand-50">
|
||||||
|
<Phone className="w-5 h-5 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider">Téléphone</p>
|
||||||
|
<p className="text-sm font-medium truncate">{business.contactPhone}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.showSocials && business.socialLinks && (
|
||||||
|
<div className="pt-4 border-t border-gray-50">
|
||||||
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{business.socialLinks.facebook && (
|
||||||
|
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
|
||||||
|
<Facebook className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.linkedin && (
|
||||||
|
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
|
||||||
|
<Linkedin className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.instagram && (
|
||||||
|
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
|
||||||
|
<Instagram className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{business.socialLinks.twitter && (
|
||||||
|
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
|
||||||
|
<Twitter className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="contact-form" ref={contactRef} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 md:p-8">
|
<div id="contact-form" ref={contactRef} 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">Contacter l'entreprise</h2>
|
<h2 className="text-xl font-bold font-serif text-gray-900 mb-6">Contacter l'entreprise</h2>
|
||||||
<form onSubmit={handleContactSubmit} className="space-y-4">
|
<form onSubmit={handleContactSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const AnnuairePageContent = () => {
|
|||||||
const [filterCategory, setFilterCategory] = useState('All');
|
const [filterCategory, setFilterCategory] = useState('All');
|
||||||
const [filterCountry, setFilterCountry] = useState('All');
|
const [filterCountry, setFilterCountry] = useState('All');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [locationQuery, setLocationQuery] = useState('');
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// 1. Fetch Metadata (Countries & Categories)
|
// 1. Fetch Metadata (Countries & Categories)
|
||||||
@@ -69,6 +70,7 @@ const AnnuairePageContent = () => {
|
|||||||
if (filterCategory !== 'All') params.append('category', filterCategory);
|
if (filterCategory !== 'All') params.append('category', filterCategory);
|
||||||
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
if (filterCountry !== 'All') params.append('countryId', filterCountry);
|
||||||
if (searchQuery) params.append('q', searchQuery);
|
if (searchQuery) params.append('q', searchQuery);
|
||||||
|
if (locationQuery) params.append('location', locationQuery);
|
||||||
|
|
||||||
const res = await fetch(`/api/businesses?${params.toString()}`, {
|
const res = await fetch(`/api/businesses?${params.toString()}`, {
|
||||||
headers: user ? { 'x-user-id': user.id } : {}
|
headers: user ? { 'x-user-id': user.id } : {}
|
||||||
@@ -86,12 +88,15 @@ const AnnuairePageContent = () => {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
const timeoutId = setTimeout(fetchBusinesses, 300); // Debounce
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filterCategory, searchQuery, filterCountry]);
|
}, [filterCategory, searchQuery, filterCountry, locationQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
if (q) setSearchQuery(q);
|
if (q) setSearchQuery(q);
|
||||||
|
|
||||||
|
const loc = searchParams.get('location');
|
||||||
|
if (loc) setLocationQuery(loc);
|
||||||
|
|
||||||
const cat = searchParams.get('category');
|
const cat = searchParams.get('category');
|
||||||
if (cat) setFilterCategory(cat);
|
if (cat) setFilterCategory(cat);
|
||||||
|
|
||||||
@@ -124,6 +129,17 @@ const AnnuairePageContent = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Ville / Localisation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={locationQuery}
|
||||||
|
onChange={(e) => setLocationQuery(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="Ex: Abidjan, Dakar..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Pays</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const q = searchParams.get('q')?.toLowerCase()
|
const q = searchParams.get('q')?.toLowerCase()
|
||||||
|
const location = searchParams.get('location')?.toLowerCase()
|
||||||
const featured = searchParams.get('featured')
|
const featured = searchParams.get('featured')
|
||||||
const countryId = searchParams.get('countryId')
|
const countryId = searchParams.get('countryId')
|
||||||
|
|
||||||
@@ -19,11 +20,12 @@ export async function GET(request: NextRequest) {
|
|||||||
isSuspended: false
|
isSuspended: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the conditions array
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (category && category !== 'All') {
|
if (category && category !== 'All') {
|
||||||
// Robust check: IDs are usually alphanumeric and long, without spaces or special chars
|
|
||||||
// Names like "Agriculture & Agrobusiness" have spaces or special chars.
|
|
||||||
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
|
const isId = /^[a-z0-9-]+$/.test(category) && category.length > 20;
|
||||||
|
|
||||||
if (isId) {
|
if (isId) {
|
||||||
where.categoryId = category
|
where.categoryId = category
|
||||||
} else {
|
} else {
|
||||||
@@ -32,13 +34,30 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
if (featured === 'true') where.isFeatured = true
|
if (featured === 'true') where.isFeatured = true
|
||||||
if (countryId) where.countryId = countryId
|
if (countryId) where.countryId = countryId
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
where.OR = [
|
conditions.push({
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
OR: [
|
||||||
{ description: { contains: q, mode: 'insensitive' } },
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
// Simplified tags check
|
{ description: { contains: q, mode: 'insensitive' } },
|
||||||
{ tags: { has: q } }
|
{ city: { contains: q, mode: 'insensitive' } },
|
||||||
]
|
{ location: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ tags: { has: q } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
conditions.push({
|
||||||
|
OR: [
|
||||||
|
{ city: { contains: location, mode: 'insensitive' } },
|
||||||
|
{ location: { contains: location, mode: 'insensitive' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
where.AND = conditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbBusinesses = await prisma.business.findMany({
|
const dbBusinesses = await prisma.business.findMany({
|
||||||
|
|||||||
@@ -279,10 +279,12 @@ const DashboardContent = () => {
|
|||||||
{currentView === 'subscription' && 'Mon Abonnement'}
|
{currentView === 'subscription' && 'Mon Abonnement'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<a href={displayBusiness.id === 'new' ? '/annuaire' : `/annuaire/${displayBusiness.slug || displayBusiness.id}`} 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">
|
{business?.isActive && (
|
||||||
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
<Link href={`/annuaire/${business.slug || business.id}`} 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 transition-all hover:scale-105 active:scale-95">
|
||||||
Voir ma fiche
|
<Eye className="-ml-1 mr-2 h-4 w-4 text-gray-500" />
|
||||||
</a>
|
Voir ma fiche
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ body {
|
|||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
hyphens: auto;
|
hyphens: none;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: normal;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
|
|||||||
@@ -39,11 +39,25 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
{isDev && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
:root {
|
||||||
|
--color-brand-50: #fef2f2 !important;
|
||||||
|
--color-brand-100: #fee2e2 !important;
|
||||||
|
--color-brand-200: #fecaca !important;
|
||||||
|
--color-brand-500: #ef4444 !important;
|
||||||
|
--color-brand-600: #dc2626 !important;
|
||||||
|
--color-brand-700: #b91c1c !important;
|
||||||
|
--color-brand-900: #7f1d1d !important;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
|
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
|||||||
14
components/DynamicProviders.tsx
Normal file
14
components/DynamicProviders.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
// On importe les Providers reellement en mode Client-Only (pas de SSR)
|
||||||
|
const Providers = dynamic(
|
||||||
|
() => import('./Providers').then((mod) => mod.Providers),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function DynamicProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Providers>{children}</Providers>;
|
||||||
|
}
|
||||||
@@ -16,12 +16,13 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
const pathname = usePathname() || '';
|
const pathname = usePathname() || '';
|
||||||
|
|
||||||
const siteName = settings?.siteName || "Afrohub";
|
const siteName = settings?.siteName || "Afrohub";
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_APP_ENV === 'development';
|
||||||
|
|
||||||
// Hide Navbar on Dashboard to prevent double navigation, show only on public pages
|
// Hide Navbar on Dashboard to prevent double navigation, show only on public pages
|
||||||
if (pathname.startsWith('/dashboard')) return null;
|
if (pathname.startsWith('/dashboard')) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-white shadow-sm sticky top-0 z-50">
|
<nav className={`shadow-sm sticky top-0 z-50 ${isDev ? 'bg-red-600/90 backdrop-blur-sm' : 'bg-white'}`}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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 justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -29,19 +30,19 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">
|
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">
|
||||||
{siteName.charAt(0)}
|
{siteName.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-serif font-bold text-xl text-gray-900">{siteName}</span>
|
<span className={`font-serif font-bold text-xl ${isDev ? 'text-white' : 'text-gray-900'}`}>{siteName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
<div className="hidden sm:ml-8 sm:flex sm:space-x-8">
|
||||||
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Accueil
|
Accueil
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/annuaire" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/annuaire') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
<Link href="/annuaire" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/annuaire') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Annuaire
|
Annuaire
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? 'border-brand-500 text-brand-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
<Link href="/afrolife" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/afrolife') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-brand-600') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Afro Life
|
Afro Life
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
<Link href="/blog" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname.startsWith('/blog') ? (isDev ? 'border-white text-white' : 'border-brand-500 text-gray-900') : (isDev ? 'border-transparent text-red-100 hover:text-white hover:border-red-200' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300')}`}>
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,14 +56,14 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/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">
|
<Link href="/login" className={`inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md focus:outline-none ${isDev ? 'border-white/30 text-white bg-white/10 hover:bg-white/20' : 'border-gray-300 text-gray-700 bg-white hover:bg-gray-50'}`}>
|
||||||
<UserIcon className="w-4 h-4 mr-2" />
|
<UserIcon className="w-4 h-4 mr-2" />
|
||||||
Connexion / Inscription
|
Connexion / Inscription
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-mr-2 flex items-center sm:hidden">
|
<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">
|
<button onClick={() => setIsOpen(!isOpen)} className={`inline-flex items-center justify-center p-2 rounded-md focus:outline-none ${isDev ? 'text-white hover:text-red-100 hover:bg-white/10' : 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'}`}>
|
||||||
{isOpen ? <X className="block h-6 w-6" /> : <Menu className="block h-6 w-6" />}
|
{isOpen ? <X className="block h-6 w-6" /> : <Menu className="block h-6 w-6" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +72,7 @@ const Navbar = ({ settings }: NavbarProps) => {
|
|||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="sm:hidden bg-white border-t">
|
<div className={`sm:hidden border-t ${isDev ? 'bg-red-600/95 border-red-500/30' : 'bg-white'}`}>
|
||||||
<div className="pt-2 pb-3 space-y-1">
|
<div className="pt-2 pb-3 space-y-1">
|
||||||
<Link href="/" 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 href="/" 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 href="/annuaire" 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 href="/annuaire" 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>
|
||||||
|
|||||||
18
components/Providers.tsx
Normal file
18
components/Providers.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { UserProvider } from './UserProvider';
|
||||||
|
import AnalyticsTracker from './AnalyticsTracker';
|
||||||
|
import CookieBanner from './CookieBanner';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<UserProvider>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
<AnalyticsTracker />
|
||||||
|
{children}
|
||||||
|
<CookieBanner />
|
||||||
|
</UserProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ interface Props {
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
|
onlySuggestions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
export default function TagInput({ value, onChange, label, placeholder, suggestions = [], onlySuggestions = false }: Props) {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
@@ -52,7 +53,15 @@ export default function TagInput({ value, onChange, label, placeholder, suggesti
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addTag = (tagToAdd?: string) => {
|
const addTag = (tagToAdd?: string) => {
|
||||||
|
if (onlySuggestions && !tagToAdd) return;
|
||||||
|
|
||||||
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||||
|
|
||||||
|
// If restricted, check if tag exists in suggestions
|
||||||
|
if (onlySuggestions && tagToAdd && !suggestions.some(s => s.toLowerCase() === tag)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tag && !value.includes(tag)) {
|
if (tag && !value.includes(tag)) {
|
||||||
onChange([...value, tag]);
|
onChange([...value, tag]);
|
||||||
}
|
}
|
||||||
@@ -69,10 +78,10 @@ export default function TagInput({ value, onChange, label, placeholder, suggesti
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||||
addTag(filteredSuggestions[selectedIndex]);
|
addTag(filteredSuggestions[selectedIndex]);
|
||||||
} else {
|
} else if (!onlySuggestions) {
|
||||||
addTag();
|
addTag();
|
||||||
}
|
}
|
||||||
} else if (e.key === ',' || e.key === ';') {
|
} else if ((e.key === ',' || e.key === ';') && !onlySuggestions) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag();
|
addTag();
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle, Hash } from 'lucide-react';
|
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, Twitter, CheckCircle, AlertCircle, Hash } from 'lucide-react';
|
||||||
import { Business, Country } from '../../types';
|
import { Business, Country } from '../../types';
|
||||||
import TagInput from '../TagInput';
|
import TagInput from '../TagInput';
|
||||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useUser } from '../UserProvider';
|
import { useUser } from '../UserProvider';
|
||||||
|
import { uploadImage } from '../../app/actions/upload';
|
||||||
|
import { Loader2, Upload, Link as LinkIcon } from 'lucide-react';
|
||||||
|
|
||||||
// Helper to extract youtube ID
|
// Helper to extract youtube ID
|
||||||
const getYouTubeId = (url: string) => {
|
const getYouTubeId = (url: string) => {
|
||||||
@@ -40,6 +42,7 @@ const normalizeBusinessData = (b: Business): Business => ({
|
|||||||
facebook: b.socialLinks?.facebook || '',
|
facebook: b.socialLinks?.facebook || '',
|
||||||
linkedin: b.socialLinks?.linkedin || '',
|
linkedin: b.socialLinks?.linkedin || '',
|
||||||
instagram: b.socialLinks?.instagram || '',
|
instagram: b.socialLinks?.instagram || '',
|
||||||
|
twitter: b.socialLinks?.twitter || '',
|
||||||
website: b.socialLinks?.website || '',
|
website: b.socialLinks?.website || '',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -54,6 +57,12 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [allTags, setAllTags] = useState<string[]>([]);
|
const [allTags, setAllTags] = useState<string[]>([]);
|
||||||
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||||
|
const [isUploadingCover, setIsUploadingCover] = useState(false);
|
||||||
|
const [logoInputMode, setLogoInputMode] = useState<'url' | 'upload'>('upload');
|
||||||
|
const [coverInputMode, setCoverInputMode] = useState<'url' | 'upload'>('upload');
|
||||||
|
const logoFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const coverFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
// Fetch countries, categories and tags
|
// Fetch countries, categories and tags
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -112,6 +121,74 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("L'image ne doit pas dépasser 5 Mo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingLogo(true);
|
||||||
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(uploadFormData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
setFormData({ ...formData, logoUrl: result.url });
|
||||||
|
toast.success("Logo mis à jour !");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur réseau lors de l'upload");
|
||||||
|
} finally {
|
||||||
|
setIsUploadingLogo(false);
|
||||||
|
if (logoFileInputRef.current) logoFileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("L'image ne doit pas dépasser 5 Mo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingCover(true);
|
||||||
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(uploadFormData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
setFormData({ ...formData, coverUrl: result.url });
|
||||||
|
toast.success("Bannière mise à jour !");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur réseau lors de l'upload");
|
||||||
|
} finally {
|
||||||
|
setIsUploadingCover(false);
|
||||||
|
if (coverFileInputRef.current) coverFileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/businesses', {
|
const res = await fetch('/api/businesses', {
|
||||||
@@ -233,19 +310,94 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
{/* A. Bloc Identité Visuelle */}
|
{/* A. Bloc Identité Visuelle */}
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<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>
|
<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="flex flex-col md:flex-row md:items-center gap-6">
|
||||||
<div className="shrink-0 flex flex-col items-center gap-2">
|
<div className="shrink-0 flex flex-col items-center gap-2">
|
||||||
<img className="h-24 w-24 object-cover rounded-full border-2 border-gray-200" src={formData.logoUrl} alt="Logo actuel" />
|
<img
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase">Logo</span>
|
className="h-24 w-24 object-cover rounded-full border-2 border-brand-200 shadow-sm"
|
||||||
|
src={formData.logoUrl || "https://picsum.photos/200/200?random=logo"}
|
||||||
|
alt="Logo actuel"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Aperçu Logo</span>
|
||||||
</div>
|
</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" />
|
<div className="flex-1 space-y-4">
|
||||||
<p className="mt-1 text-xs text-gray-500">Modifier le logo</p>
|
<div className="flex bg-gray-100 p-1 rounded-lg w-fit border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setLogoInputMode('upload')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-2 ${logoInputMode === 'upload' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" /> Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLogoInputMode('url')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-2 ${logoInputMode === 'url' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" /> Lien URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logoInputMode === 'upload' ? (
|
||||||
|
<div
|
||||||
|
onClick={() => logoFileInputRef.current?.click()}
|
||||||
|
className="group border-2 border-dashed border-gray-300 rounded-xl p-6 flex flex-col items-center justify-center hover:border-brand-400 hover:bg-brand-50 transition-all cursor-pointer relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{isUploadingLogo ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
|
||||||
|
<span className="text-xs font-bold text-brand-600">Upload en cours...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-brand-100 group-hover:text-brand-600 transition-colors mb-2">
|
||||||
|
<Upload className="w-5 h-5 text-gray-400 group-hover:text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-gray-700">Cliquez pour changer le logo</p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">PNG, JPG ou WEBP (Max 5Mo)</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={logoFileInputRef}
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="logoUrl"
|
||||||
|
value={formData.logoUrl || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="URL de votre logo (ex: https://...)"
|
||||||
|
className="block w-full border border-gray-300 rounded-lg py-2.5 pl-10 pr-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Photo de Couverture (Bannière)</label>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Photo de Couverture (Bannière)</label>
|
||||||
|
<div className="flex bg-gray-100 p-1 rounded-lg border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setCoverInputMode('upload')}
|
||||||
|
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all flex items-center gap-1.5 ${coverInputMode === 'upload' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-3 h-3" /> Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCoverInputMode('url')}
|
||||||
|
className={`px-3 py-1 rounded-md text-[10px] font-bold transition-all flex items-center gap-1.5 ${coverInputMode === 'url' ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3 h-3" /> URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative h-48 w-full rounded-lg overflow-hidden bg-gray-100 border-2 border-dashed border-gray-300 group transition-all">
|
<div className="relative h-48 w-full rounded-lg overflow-hidden bg-gray-100 border-2 border-dashed border-gray-300 group transition-all">
|
||||||
{formData.coverUrl ? (
|
{formData.coverUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -263,12 +415,33 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
<span className="text-xs">Aucune bannière personnalisée</span>
|
<span className="text-xs">Aucune bannière personnalisée</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
||||||
<button className="bg-white text-gray-900 px-4 py-1.5 rounded-full text-xs font-bold shadow-lg">Changer la photo</button>
|
{isUploadingCover && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 backdrop-blur-sm flex flex-col items-center justify-center gap-2 z-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
|
||||||
|
<span className="text-xs font-bold text-brand-600">Upload en cours...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => coverFileInputRef.current?.click()}
|
||||||
|
className="bg-white text-gray-900 px-4 py-1.5 rounded-full text-xs font-bold shadow-lg flex items-center gap-2 hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" /> Changer la photo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={coverFileInputRef}
|
||||||
|
onChange={handleCoverUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-200">
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||||
|
{/* ... (Zoom/Position controls stay same) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Zoom : {(formData.coverZoom || 1).toFixed(1)}x</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Zoom : {(formData.coverZoom || 1).toFixed(1)}x</label>
|
||||||
<input
|
<input
|
||||||
@@ -307,14 +480,16 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{coverInputMode === 'url' && (
|
||||||
type="text"
|
<input
|
||||||
name="coverUrl"
|
type="text"
|
||||||
value={formData.coverUrl || ''}
|
name="coverUrl"
|
||||||
onChange={handleInputChange}
|
value={formData.coverUrl || ''}
|
||||||
placeholder="URL de votre image de couverture (ex: https://...)"
|
onChange={handleInputChange}
|
||||||
className="mt-4 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"
|
placeholder="URL de votre image de couverture (ex: https://...)"
|
||||||
/>
|
className="mt-4 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 animate-in fade-in slide-in-from-top-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="mt-1 text-[10px] text-gray-500 italic">Ajustez le zoom et la position pour un rendu optimal sur votre fiche.</p>
|
<p className="mt-1 text-[10px] text-gray-500 italic">Ajustez le zoom et la position pour un rendu optimal sur votre fiche.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
|
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
|
||||||
@@ -389,8 +564,9 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
value={formData.tags || []}
|
value={formData.tags || []}
|
||||||
onChange={(tags) => setFormData({ ...formData, tags })}
|
onChange={(tags) => setFormData({ ...formData, tags })}
|
||||||
suggestions={allTags}
|
suggestions={allTags}
|
||||||
|
onlySuggestions={true}
|
||||||
label="Mots-clés & Tags"
|
label="Mots-clés & Tags"
|
||||||
placeholder="innovation, tech, artisanat..."
|
placeholder="Choisir dans la liste..."
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-gray-500 italic">Ces tags aideront les clients à trouver votre boutique via la recherche.</p>
|
<p className="mt-1 text-[10px] text-gray-500 italic">Ces tags aideront les clients à trouver votre boutique via la recherche.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,6 +722,12 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
</span>
|
</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" />
|
<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 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">
|
||||||
|
<Twitter className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<input type="text" placeholder="Lien Twitter (X)" value={formData.socialLinks?.twitter || ''} onChange={(e) => handleSocialChange('twitter', 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>
|
</div>
|
||||||
|
|||||||
@@ -77,3 +77,9 @@ npx prisma studio
|
|||||||
- **Offer** : Produits ou Services liés à un `Business`.
|
- **Offer** : Produits ou Services liés à un `Business`.
|
||||||
- **BlogPost** : Les articles du blog.
|
- **BlogPost** : Les articles du blog.
|
||||||
- **Interview** : Les vidéos et articles d'interviews.
|
- **Interview** : Les vidéos et articles d'interviews.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma generate // Regénère le client Prisma
|
||||||
|
prisma migrate dev // Crée/Met à jour les tables dans la BDD
|
||||||
|
prisma studio // Ouvre l'interface web de gestion des données
|
||||||
|
```
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const nextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
Reference in New Issue
Block a user