fix uploads

fix refresh page on navbar hem/logo
fix messages icon notification
This commit is contained in:
ismail 2026-02-02 19:14:55 +01:00
parent 0dbddac214
commit a17c82aa30
8 changed files with 220 additions and 135 deletions

View File

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

View File

@ -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<RowDataPacket[]>(
`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}`,
`
<h2>Du har fått ett svar</h2>
<p><strong>Ämne:</strong> ${conversation.subject}</p>
<p><strong>Meddelande:</strong></p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/messages/${id}">Se konversation</a></p>
`
'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}`,
`
<h2>Kundsvar</h2>
<p><strong>Från:</strong> ${conversation.user_name}</p>
<p><strong>Meddelande:</strong></p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/admin/messages/${id}">Svara här</a></p>
`
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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}`,
`
<h2>Ditt ärende har markerats som löst</h2>
<p>Hej ${conversation.user_name || 'kund'},</p>
<p>Vi har markerat ditt ärende "<strong>${conversation.subject}</strong>" som löst.</p>
<p>Om du fortfarande har frågor eller känner att ärendet inte är helt löst, tveka inte att kontakta oss igen.</p>
<p>Med vänliga hälsningar,<br/>Nordic Storium Support</p>
`
);
);
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 });
}
}

View File

@ -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<Conversation[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(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) {

View File

@ -118,7 +118,7 @@ export default function CookieConsentModal() {
Vi värdesätter transparens. Genom att klicka &quot;Godkänn alla&quot; samtycker du till användningen av alla cookies. Du kan när som helst ändra dina val eller återkalla ditt samtycke under &quot;Anpassa&quot; eller via länken i sidfoten.
</p>
<p className="text-xs sm:text-sm border-t border-[var(--border-color)] pt-4 mt-4">
Personuppgiftsansvarig: <strong>Nordic Storium</strong> (Org.nr 559287-3612). <br />
Personuppgiftsansvarig: <strong>Nordic Storium</strong><br />
Läs mer om dina rättigheter i vår <Link href="/integritetspolicy" className="text-blue-600 hover:underline font-medium">Integritetspolicy</Link> och <Link href="/villkor" className="text-blue-600 hover:underline font-medium">Köpvillkor</Link>.
</p>
</div>

View File

@ -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() {
<ul className="nav-links desktop-links">
<li className={pathname === "/" ? "current-menu-item" : ""}>
<Link href="/" prefetch={true}>HEM</Link>
<a href="/">HEM</a>
</li>
<li className={pathname === "/products" ? "current-menu-item" : ""}>
<Link href="/products" prefetch={true}>PRODUKTER</Link>
@ -61,7 +62,7 @@ export default function Navbar() {
{/* Center: Logo Only */}
<div className="nav-center">
<Link href="/" className="logo-link">
<a href="/" className="logo-link">
<Image
src={logo}
alt="Logo"
@ -70,7 +71,7 @@ export default function Navbar() {
className="logo"
priority
/>
</Link>
</a>
</div>
{/* 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 = () => {

View File

@ -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
>
<AuthProvider>
<ToastProvider>
{children}
</ToastProvider>
<NotificationProvider>
<ToastProvider>
{children}
</ToastProvider>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
);

View File

@ -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<void>;
}
const NotificationContext = createContext<NotificationContextType | undefined>(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 (
<NotificationContext.Provider value={{ unreadCount, refreshUnreadCount: fetchUnread }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotification() {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error("useNotification must be used within a NotificationProvider");
}
return context;
}

View File

@ -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 = `
<h2 style="font-size: 16px; font-weight: 800; text-transform: uppercase; margin-bottom: 20px; letter-spacing: 1px;">Nytt Meddelande</h2>
<div style="background: #f7fafc; padding: 20px; border-left: 4px solid #000000; margin-bottom: 30px;">
<p style="font-size: 14px; margin: 0 0 10px 0;"><strong>Från:</strong> ${senderName}</p>
<p style="font-size: 14px; margin: 0 0 10px 0;"><strong>Email:</strong> <a href="mailto:${senderEmail}" style="color: #000000;">${senderEmail}</a></p>
${senderPhone ? `<p style="font-size: 14px; margin: 0 0 10px 0;"><strong>Telefon:</strong> ${senderPhone}</p>` : ''}
${senderPersonnummer ? `<p style="font-size: 14px; margin: 0;"><strong>Personnummer:</strong> ${senderPersonnummer}</p>` : ''}
</div>
<div style="background: #ffffff; padding: 20px; border: 1px solid #e2e8f0; margin-bottom: 30px;">
<h3 style="font-size: 14px; font-weight: 700; text-transform: uppercase; margin: 0 0 15px 0; letter-spacing: 1px;">Meddelande:</h3>
<blockquote style="font-size: 14px; line-height: 1.7; color: #444444; margin: 0; padding-left: 10px; border-left: 3px solid #e2e8f0;">
${messageContent}
</blockquote>
</div>
<a href="${actionLink}" style="display: block; background: #000000; color: #ffffff; text-align: center; padding: 16px; text-decoration: none; font-weight: 700; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; border-radius: 4px;">
${actionText}
</a>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
<p style="font-size: 12px; color: #999999;">
Klicka knappen ovan för att svara direkt i portalen.
</p>
</div>
`;
return sendEmail(toEmail, subject, premiumTemplate(content));
};