diff --git a/admin/src/app/actions/business.ts b/admin/src/app/actions/business.ts index ecc2017..3ff72d6 100644 --- a/admin/src/app/actions/business.ts +++ b/admin/src/app/actions/business.ts @@ -44,7 +44,7 @@ export async function setFeaturedBusiness(id: string, data: { return { success: true }; } catch (error) { console.error("Failed to set featured business:", error); - return { success: false, error: "Erreur lors de la mise à jour de l'entrepreneur du mois" }; + return { success: false, error: "Erreur lors de la mise à jour de l'Afroshine" }; } } diff --git a/admin/src/components/FeaturedModal.tsx b/admin/src/components/FeaturedModal.tsx index cf1ae40..1b59440 100644 --- a/admin/src/components/FeaturedModal.tsx +++ b/admin/src/components/FeaturedModal.tsx @@ -32,7 +32,7 @@ export default function FeaturedModal({ business }: Props) { startTransition(async () => { const result = await setFeaturedBusiness(business.id, data); if (result.success) { - toast.success("Entrepreneur du mois mis à jour !"); + toast.success("Afroshine mis à jour !"); setIsOpen(false); } else { toast.error(result.error || "Une erreur est survenue"); @@ -41,7 +41,7 @@ export default function FeaturedModal({ business }: Props) { }; const handleRemove = async () => { - if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) { + if (confirm("Retirer cet entrepreneur du titre 'Afroshine' ?")) { startTransition(async () => { const result = await removeFeaturedBusiness(business.id); if (result.success) { @@ -79,7 +79,7 @@ export default function FeaturedModal({ business }: Props) {
-

💰 Entrepreneur du Mois

+

💰 Afroshine

Configurez les détails pour {business.name}.

@@ -124,7 +124,7 @@ export default function FeaturedModal({ business }: Props) { className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors" > {isPending ? : } - Définir comme Entrepreneur du Mois + Définir comme Afroshine {business.isFeatured && ( diff --git a/app/annuaire/[id]/page.tsx b/app/annuaire/[id]/page.tsx index 4eea65c..85af3c3 100644 --- a/app/annuaire/[id]/page.tsx +++ b/app/annuaire/[id]/page.tsx @@ -440,7 +440,7 @@ const BusinessDetailPage = () => {
{business.name} {business.isFeatured && ( -
+
)} @@ -458,7 +458,7 @@ const BusinessDetailPage = () => {
{business.category}
{business.isFeatured && (
- ENTREPRENEUR DU MOIS + AFROSHINE
)}
diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..f226a75 --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; +import { sendEmail } from '@/lib/mail'; +import crypto from 'crypto'; + +export async function POST(req: Request) { + try { + const { email } = await req.json(); + + if (!email) { + return NextResponse.json({ error: 'Email est requis' }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + // Pour des raisons de sécurité, on ne dit pas si l'utilisateur existe ou non + return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' }); + } + + // Générer un token + const token = crypto.randomBytes(32).toString('hex'); + const expiry = new Date(Date.now() + 3600000); // 1 heure + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetToken: token, + resetTokenExpiry: expiry, + }, + }); + + const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`; + + await sendEmail({ + to: email, + subject: 'Réinitialisation de votre mot de passe - Afrohub', + html: ` +
+

Afrohub

+

Bonjour ${user.name},

+

Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :

+
+ Réinitialiser mon mot de passe +
+

Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.

+
+

© 2025 Afrohub. Tous droits réservés.

+
+ `, + }); + + return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' }); + } catch (error: any) { + console.error('Error in forgot-password:', error); + return NextResponse.json({ error: 'Une erreur est survenue lors de la demande' }, { status: 500 }); + } +} diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..23246a8 --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; +import bcrypt from 'bcryptjs'; + +export async function POST(req: Request) { + try { + const { token, password } = await req.json(); + + if (!token || !password) { + return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 }); + } + + const user = await prisma.user.findFirst({ + where: { + resetToken: token, + resetTokenExpiry: { + gt: new Date(), + }, + }, + }); + + if (!user) { + return NextResponse.json({ error: 'Token invalide ou expiré' }, { status: 400 }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hashedPassword, + resetToken: null, + resetTokenExpiry: null, + }, + }); + + return NextResponse.json({ message: 'Votre mot de passe a été mis à jour avec succès' }); + } catch (error: any) { + console.error('Error in reset-password:', error); + return NextResponse.json({ error: 'Une erreur est survenue lors de la réinitialisation' }, { status: 500 }); + } +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 0000000..2449014 --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useUser } from '@/components/UserProvider'; + +const ForgotPasswordPage = () => { + const { settings } = useUser(); + const siteName = settings?.siteName || "Afrohub"; + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + setMessage(''); + + try { + const res = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'Une erreur est survenue'); + } + + setMessage(data.message); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ {siteName.charAt(0)} +
+

Mot de passe oublié

+

+ Entrez votre adresse email pour recevoir un lien de réinitialisation +

+
+ + {message ? ( +
+ {message} +
+ Retourner à la connexion +
+
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} +
+
+ setEmail(e.target.value)} + /> +
+
+
+ +
+
+ Retourner à la connexion +
+
+ )} +
+
+ ); +}; + +export default ForgotPasswordPage; diff --git a/app/login/page.tsx b/app/login/page.tsx index 6149abd..58cc5df 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -83,6 +83,13 @@ const LoginPage = () => { />
+
+
+ + Mot de passe oublié ? + +
+
+
+ + )} + + + ); +}; + +const ResetPasswordPage = () => { + return ( + Chargement...}> + + + ); +}; + +export default ResetPasswordPage; diff --git a/app/sitemap.ts b/app/sitemap.ts index 26fd6e1..8290fbe 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -72,7 +72,19 @@ export default async function sitemap(): Promise { priority: 0.7, })); - return [...staticRoutes, ...businessRoutes, ...blogRoutes]; + // 4. Fetch all Afro Life (Interviews) + const interviews = await prisma.interview.findMany({ + select: { id: true, slug: true, updatedAt: true }, + }); + + const afroLifeRoutes = interviews.map((interview) => ({ + url: `${baseUrl}/afrolife/${interview.slug || interview.id}`, + lastModified: interview.updatedAt, + changeFrequency: 'monthly' as const, + priority: 0.7, + })); + + return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes]; } catch (error) { console.error("Error generating sitemap:", error); // Fallback to only static routes if DB connection fails diff --git a/components/AnnuaireHero.tsx b/components/AnnuaireHero.tsx index 20a4221..7816f6b 100644 --- a/components/AnnuaireHero.tsx +++ b/components/AnnuaireHero.tsx @@ -49,7 +49,7 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
- Entrepreneur du mois + Afroshine
@@ -81,7 +81,7 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => { Voir la fiche complète
diff --git a/lib/mail.ts b/lib/mail.ts new file mode 100644 index 0000000..9d7b409 --- /dev/null +++ b/lib/mail.ts @@ -0,0 +1,29 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +export const sendEmail = async ({ to, subject, html }: { to: string; subject: string; html: string }) => { + const mailOptions = { + from: process.env.SMTP_FROM || '"Afrohub" ', + to, + subject, + html, + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Email sent: ' + info.response); + return info; + } catch (error) { + console.error('Error sending email:', error); + throw error; + } +}; diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index c4ebb9f..5003f60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "jiti": "^2.6.1", "lucide-react": "^0.554.0", "next": "^16.1.6", + "nodemailer": "^8.0.6", "pg": "^8.19.0", "postcss": "^8.5.10", "prisma": "6.19.3", @@ -35,6 +36,7 @@ "typescript": "~5.8.2" }, "devDependencies": { + "@types/nodemailer": "^8.0.0", "concurrently": "^9.2.1" }, "engines": { @@ -1713,6 +1715,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -3139,6 +3151,15 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz", + "integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", diff --git a/package.json b/package.json index cd4d0aa..d797a50 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "jiti": "^2.6.1", "lucide-react": "^0.554.0", "next": "^16.1.6", + "nodemailer": "^8.0.6", "pg": "^8.19.0", "postcss": "^8.5.10", "prisma": "6.19.3", @@ -45,6 +46,7 @@ "typescript": "~5.8.2" }, "devDependencies": { + "@types/nodemailer": "^8.0.0", "concurrently": "^9.2.1" }, "prisma": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75b94f1..90968ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,8 @@ model User { password String role UserRole @default(VISITOR) avatar String? + resetToken String? @unique + resetTokenExpiry DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bio String?