Compare commits

..

25 Commits

Author SHA1 Message Date
371ed17867 Merge branch 'develop' 2026-05-09 21:35:43 +02:00
f6e3c1bb8c feat: add Twitter support to business profile and implement DashboardProfile component 2026-05-09 21:19:52 +02:00
6acd5ebe2e feat: implement business detail client view with interactive features including rating, favoriting, messaging, and analytics tracking 2026-05-09 21:12:01 +02:00
6a42c52b58 ok 2026-05-09 12:23:29 +02:00
72c24e0cc6 rectification branch develop 2026-05-09 12:23:26 +02:00
67df2d95e9 correction voir ma fiche 2026-05-09 12:13:36 +02:00
4aedcdb45a correction turbo pack 2026-05-09 11:02:48 +02:00
d1b32cd401 feat: add global error boundary component for handling critical application errors 2026-05-06 21:56:52 +02:00
2148f7281e docs: remove obsolete project configuration and documentation files 2026-05-06 21:14:58 +02:00
7b015cb758 hul correctuib 2026-05-06 21:00:15 +02:00
bb464cf1c5 feat: implement DynamicProviders to disable SSR for global context providers in root layout 2026-05-06 20:47:29 +02:00
19b405d9f2 feat: implement RootLayout with dynamic provider loading and metadata generation 2026-05-06 20:37:39 +02:00
a37b34f758 feat: initialize root layout with dynamic provider rendering and site metadata configuration 2026-05-06 20:28:21 +02:00
13c88dd4f7 ajout provider pour rendre le layout dynamique 2026-05-06 20:19:09 +02:00
52a9d47c30 correction global error détachement du layout 2026-05-06 19:53:36 +02:00
f93d70c447 suppression du run build admin pour le build normal 2026-05-06 17:29:16 +02:00
c73763be8a suppression build .next erreur global-error 2026-05-06 17:22:36 +02:00
268e14f369 modification du global error 2026-05-06 09:03:06 +02:00
e1aa0f076a restauration fichier docker 2026-05-06 08:19:46 +02:00
3d7cb5f4d1 maj plantage turbopack 2026-05-05 23:13:31 +02:00
2d9d707251 correction build error 2026-05-04 21:40:18 +02:00
5720661f8d maj banniere prod/dev 2026-05-04 21:30:10 +02:00
1b6d409319 maj color develop 2026-05-04 21:18:41 +02:00
63b5af4868 suppression des fichiers inutile 2026-05-04 20:47:35 +02:00
a23c3bf780 maj bdd instruction 2026-05-04 20:34:13 +02:00
25 changed files with 577 additions and 79 deletions

2
.env
View File

@@ -4,3 +4,5 @@ DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?sch
# 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

View File

@@ -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

View File

@@ -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",

View File

@@ -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
View 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' };
}
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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
} }
} }
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;
// Build the conditions array
const conditions: any[] = [];
if (category && category !== 'All') {
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({

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View 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>;
}

View File

@@ -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
View 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>
);
}

View File

@@ -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') {

View File

@@ -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>

View File

@@ -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
View File

@@ -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.

View File

@@ -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

View File

@@ -24,6 +24,7 @@ export interface SocialLinks {
facebook?: string; facebook?: string;
linkedin?: string; linkedin?: string;
instagram?: string; instagram?: string;
twitter?: string;
website?: string; website?: string;
} }