fix uploads
fix refresh page on navbar hem/logo fix messages icon notification
This commit is contained in:
parent
0dbddac214
commit
a17c82aa30
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 på knappen ovan för att svara direkt i portalen.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return sendEmail(toEmail, subject, premiumTemplate(content));
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue