From a17c82aa30ae3d2f11e1855b768a6dba116e39e8 Mon Sep 17 00:00:00 2001 From: ismail Date: Mon, 2 Feb 2026 19:14:55 +0100 Subject: [PATCH] fix uploads fix refresh page on navbar hem/logo fix messages icon notification --- docker-compose.yml | 2 + src/app/api/conversations/[id]/route.ts | 197 +++++++++++------------- src/app/messages/page.tsx | 4 + src/components/CookieConsentModal.tsx | 2 +- src/components/Navbar.tsx | 32 +--- src/components/Providers.tsx | 9 +- src/context/NotificationContext.tsx | 69 +++++++++ src/lib/email.ts | 40 +++++ 8 files changed, 220 insertions(+), 135 deletions(-) create mode 100644 src/context/NotificationContext.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 51416b8..c46b597 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,8 @@ services: - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_REDIRECT_URI=https://store.abrahem.se/api/auth/google/callback + volumes: + - ./public/uploads:/app/public/uploads depends_on: db: condition: service_healthy diff --git a/src/app/api/conversations/[id]/route.ts b/src/app/api/conversations/[id]/route.ts index cb0ffd3..672bad3 100644 --- a/src/app/api/conversations/[id]/route.ts +++ b/src/app/api/conversations/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import pool from '@/lib/db'; import { verifyToken, TokenPayload } from '@/lib/auth'; import { RowDataPacket, ResultSetHeader } from 'mysql2'; -import { sendEmail } from '@/lib/email'; +import { sendEmail, sendMessageNotification } from '@/lib/email'; function getUserFromRequest(request: NextRequest): TokenPayload | null { const authHeader = request.headers.get('authorization'); @@ -112,6 +112,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) { [id, user.userId, senderRole, content] ); + // Fetch sender's expanded details for the email + const [senderDetails] = await pool.query( + `SELECT full_name, email, mobile, personnummer FROM users WHERE id = ?`, + [user.userId] + ); + const sender = senderDetails[0]; + await pool.query( `UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [id] @@ -129,16 +136,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const isCustomerOnline = sessions[0].count > 0; if (!isCustomerOnline) { - await sendEmail( + await sendMessageNotification( conversation.user_email, `Nytt svar: ${conversation.subject}`, - ` -

Du har fått ett svar

-

Ämne: ${conversation.subject}

-

Meddelande:

-
${content}
-

Se konversation

- ` + 'Nordic Storium Support', + process.env.SMTP_FROM || 'support@nordicstorium.com', + null, + null, + content, + `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/messages`, + 'Se konversation' ); } } else { @@ -157,121 +164,103 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const isAdminOnline = sessions[0].count > 0; if (!isAdminOnline) { - await sendEmail( + await sendMessageNotification( admin.email, - `Nytt svar: ${conversation.subject}`, - ` -

Kundsvar

-

Från: ${conversation.user_name}

-

Meddelande:

-
${content}
-

Svara här

- ` + `Nytt meddelande: ${conversation.subject}`, + sender.full_name, + sender.email, + sender.mobile, + sender.personnummer, + content, + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const [convRows] = await pool.query( + `SELECT * FROM conversations WHERE id = ?`, + [id] ); + + if (convRows.length === 0) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } + + const conversation = convRows[0]; + + // Only admin can delete completely, customers can only delete their own + if (user.role !== 'admin' && conversation.user_id !== user.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Delete the conversation (cascades to messages) + await pool.query(`DELETE FROM conversations WHERE id = ?`, [id]); + + return NextResponse.json({ success: true, message: 'Conversation deleted' }); + } catch (error) { + console.error('Error deleting conversation:', error); + return NextResponse.json({ error: 'Server error' }, { status: 500 }); } } - } - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error sending message:', error); - return NextResponse.json({ error: 'Server error' }, { status: 500 }); - } -} + // PATCH /api/conversations/[id] - Resolve conversation (admin only) + export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const user = getUserFromRequest(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } -// DELETE /api/conversations/[id] - Delete conversation -export async function DELETE(request: NextRequest, { params }: RouteParams) { - try { - const user = getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + if (user.role !== 'admin') { + return NextResponse.json({ error: 'Admin only' }, { status: 403 }); + } - const { id } = await params; + const { id } = await params; + const body = await request.json(); + const { action } = body; - const [convRows] = await pool.query( - `SELECT * FROM conversations WHERE id = ?`, - [id] - ); - - if (convRows.length === 0) { - return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); - } - - const conversation = convRows[0]; - - // Only admin can delete completely, customers can only delete their own - if (user.role !== 'admin' && conversation.user_id !== user.userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - // Delete the conversation (cascades to messages) - await pool.query(`DELETE FROM conversations WHERE id = ?`, [id]); - - return NextResponse.json({ success: true, message: 'Conversation deleted' }); - } catch (error) { - console.error('Error deleting conversation:', error); - return NextResponse.json({ error: 'Server error' }, { status: 500 }); - } -} - -// PATCH /api/conversations/[id] - Resolve conversation (admin only) -export async function PATCH(request: NextRequest, { params }: RouteParams) { - try { - const user = getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (user.role !== 'admin') { - return NextResponse.json({ error: 'Admin only' }, { status: 403 }); - } - - const { id } = await params; - const body = await request.json(); - const { action } = body; - - if (action === 'resolve') { - // Get conversation info for email - const [convRows] = await pool.query( - `SELECT c.*, u.email as user_email, u.full_name as user_name + if (action === 'resolve') { + // Get conversation info for email + const [convRows] = await pool.query( + `SELECT c.*, u.email as user_email, u.full_name as user_name FROM conversations c JOIN users u ON c.user_id = u.id WHERE c.id = ?`, - [id] - ); + [id] + ); - if (convRows.length === 0) { - return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); - } + if (convRows.length === 0) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); + } - const conversation = convRows[0]; + const conversation = convRows[0]; - // Update status to closed - await pool.query( - `UPDATE conversations SET status = 'closed' WHERE id = ?`, - [id] - ); + // Update status to closed + await pool.query( + `UPDATE conversations SET status = 'closed' WHERE id = ?`, + [id] + ); - // Send resolution email to customer - await sendEmail( - conversation.user_email, - `Ärende löst: ${conversation.subject}`, - ` + // Send resolution email to customer + await sendEmail( + conversation.user_email, + `Ärende löst: ${conversation.subject}`, + `

Ditt ärende har markerats som löst

Hej ${conversation.user_name || 'kund'},

Vi har markerat ditt ärende "${conversation.subject}" som löst.

Om du fortfarande har frågor eller känner att ärendet inte är helt löst, tveka inte att kontakta oss igen.

Med vänliga hälsningar,
Nordic Storium Support

` - ); + ); - return NextResponse.json({ success: true, message: 'Conversation resolved' }); - } + return NextResponse.json({ success: true, message: 'Conversation resolved' }); + } - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); - } catch (error) { - console.error('Error resolving conversation:', error); - return NextResponse.json({ error: 'Server error' }, { status: 500 }); - } -} + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error) { + console.error('Error resolving conversation:', error); + return NextResponse.json({ error: 'Server error' }, { status: 500 }); + } + } diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx index ffbb905..291665d 100644 --- a/src/app/messages/page.tsx +++ b/src/app/messages/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/context/AuthContext'; +import { useNotification } from '@/context/NotificationContext'; import { useToast } from '@/components/Toast'; import { Loader2, Send, MessageCircle, Plus, Trash2, CheckCircle, AlertTriangle } from 'lucide-react'; import '@/styles/Messages.css'; @@ -35,6 +36,7 @@ export default function MessagesPage() { const { user, loading: authLoading } = useAuth(); const router = useRouter(); const { showToast } = useToast(); + const { refreshUnreadCount } = useNotification(); const [conversations, setConversations] = useState([]); const [activeConversation, setActiveConversation] = useState(null); @@ -102,6 +104,8 @@ export default function MessagesPage() { setConversations(prev => prev.map(c => c.id === convId ? { ...c, unread_count: 0 } : c )); + // Refresh global navbar count + refreshUnreadCount(); } } } catch (err) { diff --git a/src/components/CookieConsentModal.tsx b/src/components/CookieConsentModal.tsx index 8ab05fd..52f2463 100644 --- a/src/components/CookieConsentModal.tsx +++ b/src/components/CookieConsentModal.tsx @@ -118,7 +118,7 @@ export default function CookieConsentModal() { Vi värdesätter transparens. Genom att klicka på "Godkänn alla" samtycker du till användningen av alla cookies. Du kan när som helst ändra dina val eller återkalla ditt samtycke under "Anpassa" eller via länken i sidfoten.

- Personuppgiftsansvarig: Nordic Storium (Org.nr 559287-3612).
+ Personuppgiftsansvarig: Nordic Storium
Läs mer om dina rättigheter i vår Integritetspolicy och Köpvillkor.

diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index aa4e404..dc59e98 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import { User, MessageCircle } from "lucide-react"; import logo from "../assets/logo-main.png"; import "../styles/components/Navbar.css"; import { useAuth } from "../context/AuthContext"; +import { useNotification } from "../context/NotificationContext"; import NavbarSearch from "./NavbarSearch"; @@ -42,7 +43,7 @@ export default function Navbar() {
  • - HEM + HEM
  • PRODUKTER @@ -61,7 +62,7 @@ export default function Navbar() { {/* Center: Logo Only */}
    - + Logo - +
    {/* Right side: profile + theme toggle */} @@ -82,7 +83,7 @@ export default function Navbar() { function NavbarClientExtras({ isAuthenticated, isMounted }: { isAuthenticated: boolean; isMounted: boolean }) { const [isOpen, setIsOpen] = useState(false); - const [unreadCount, setUnreadCount] = useState(0); + const { unreadCount } = useNotification(); const { theme, setTheme, resolvedTheme } = useTheme(); const [showThemeToggle, setShowThemeToggle] = useState(true); @@ -124,30 +125,7 @@ function NavbarClientExtras({ isAuthenticated, isMounted }: { isAuthenticated: b return () => window.removeEventListener("resize", handleResize); }, [theme]); - // Fetch unread message count - useEffect(() => { - if (!isMounted || !isAuthenticated) return; - const fetchUnread = async () => { - try { - const token = localStorage.getItem('token') || sessionStorage.getItem('token'); - const res = await fetch('/api/conversations/unread', { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (res.ok) { - const data = await res.json(); - setUnreadCount(data.count); - } - } catch (err) { - console.error(err); - } - }; - - fetchUnread(); - // Poll every 30 seconds - const interval = setInterval(fetchUnread, 30000); - return () => clearInterval(interval); - }, [isMounted, isAuthenticated]); useEffect(() => { const handleTrigger = () => { diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 6722aab..7884d79 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -4,6 +4,7 @@ import React from "react"; import { ThemeProvider } from "./theme-provider"; import { AuthProvider } from "../context/AuthContext"; +import { NotificationProvider } from "../context/NotificationContext"; import { ToastProvider } from "./Toast"; export function Providers({ children }: { children: React.ReactNode }) { @@ -17,9 +18,11 @@ export function Providers({ children }: { children: React.ReactNode }) { disableTransitionOnChange > - - {children} - + + + {children} + + ); diff --git a/src/context/NotificationContext.tsx b/src/context/NotificationContext.tsx new file mode 100644 index 0000000..ba4ff14 --- /dev/null +++ b/src/context/NotificationContext.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { useAuth } from "./AuthContext"; + +interface NotificationContextType { + unreadCount: number; + refreshUnreadCount: () => Promise; +} + +const NotificationContext = createContext(undefined); + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const { user, isAuthenticated } = useAuth(); + const [unreadCount, setUnreadCount] = useState(0); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + const fetchUnread = useCallback(async () => { + if (!isAuthenticated) { + setUnreadCount(0); + return; + } + + try { + const token = localStorage.getItem('token') || sessionStorage.getItem('token'); + if (!token) return; + + const res = await fetch('/api/conversations/unread', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + const data = await res.json(); + setUnreadCount(data.count); + } + } catch (err) { + console.error("Failed to fetch unread count:", err); + } + }, [isAuthenticated]); + + // Initial fetch and poller + useEffect(() => { + if (!isMounted || !isAuthenticated) return; + + fetchUnread(); + + // Poll every 30 seconds + const interval = setInterval(fetchUnread, 30000); + return () => clearInterval(interval); + }, [isMounted, isAuthenticated, fetchUnread]); + + return ( + + {children} + + ); +} + +export function useNotification() { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error("useNotification must be used within a NotificationProvider"); + } + return context; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 3b0d611..595e798 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -161,3 +161,43 @@ export const sendSellFurnitureEmail = async ( return false; } }; + +export const sendMessageNotification = async ( + toEmail: string, + subject: string, + senderName: string, + senderEmail: string, + senderPhone: string | null, + senderPersonnummer: string | null, + messageContent: string, + actionLink: string, + actionText: string +) => { + const content = ` +

    Nytt Meddelande

    +
    +

    Från: ${senderName}

    +

    Email: ${senderEmail}

    + ${senderPhone ? `

    Telefon: ${senderPhone}

    ` : ''} + ${senderPersonnummer ? `

    Personnummer: ${senderPersonnummer}

    ` : ''} +
    + +
    +

    Meddelande:

    +
    + ${messageContent} +
    +
    + + + ${actionText} + + +
    +

    + Klicka på knappen ovan för att svara direkt i portalen. +

    +
    + `; + return sendEmail(toEmail, subject, premiumTemplate(content)); +};