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_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- GOOGLE_REDIRECT_URI=https://store.abrahem.se/api/auth/google/callback
|
- GOOGLE_REDIRECT_URI=https://store.abrahem.se/api/auth/google/callback
|
||||||
|
volumes:
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import pool from '@/lib/db';
|
import pool from '@/lib/db';
|
||||||
import { verifyToken, TokenPayload } from '@/lib/auth';
|
import { verifyToken, TokenPayload } from '@/lib/auth';
|
||||||
import { RowDataPacket, ResultSetHeader } from 'mysql2';
|
import { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail, sendMessageNotification } from '@/lib/email';
|
||||||
|
|
||||||
function getUserFromRequest(request: NextRequest): TokenPayload | null {
|
function getUserFromRequest(request: NextRequest): TokenPayload | null {
|
||||||
const authHeader = request.headers.get('authorization');
|
const authHeader = request.headers.get('authorization');
|
||||||
|
|
@ -112,6 +112,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
[id, user.userId, senderRole, content]
|
[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(
|
await pool.query(
|
||||||
`UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
[id]
|
[id]
|
||||||
|
|
@ -129,16 +136,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
const isCustomerOnline = sessions[0].count > 0;
|
const isCustomerOnline = sessions[0].count > 0;
|
||||||
|
|
||||||
if (!isCustomerOnline) {
|
if (!isCustomerOnline) {
|
||||||
await sendEmail(
|
await sendMessageNotification(
|
||||||
conversation.user_email,
|
conversation.user_email,
|
||||||
`Nytt svar: ${conversation.subject}`,
|
`Nytt svar: ${conversation.subject}`,
|
||||||
`
|
'Nordic Storium Support',
|
||||||
<h2>Du har fått ett svar</h2>
|
process.env.SMTP_FROM || 'support@nordicstorium.com',
|
||||||
<p><strong>Ämne:</strong> ${conversation.subject}</p>
|
null,
|
||||||
<p><strong>Meddelande:</strong></p>
|
null,
|
||||||
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
|
content,
|
||||||
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/messages/${id}">Se konversation</a></p>
|
`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/messages`,
|
||||||
`
|
'Se konversation'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -157,32 +164,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
const isAdminOnline = sessions[0].count > 0;
|
const isAdminOnline = sessions[0].count > 0;
|
||||||
|
|
||||||
if (!isAdminOnline) {
|
if (!isAdminOnline) {
|
||||||
await sendEmail(
|
await sendMessageNotification(
|
||||||
admin.email,
|
admin.email,
|
||||||
`Nytt svar: ${conversation.subject}`,
|
`Nytt meddelande: ${conversation.subject}`,
|
||||||
`
|
sender.full_name,
|
||||||
<h2>Kundsvar</h2>
|
sender.email,
|
||||||
<p><strong>Från:</strong> ${conversation.user_name}</p>
|
sender.mobile,
|
||||||
<p><strong>Meddelande:</strong></p>
|
sender.personnummer,
|
||||||
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px;">${content}</blockquote>
|
content,
|
||||||
<p><a href="${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/admin/messages/${id}">Svara här</a></p>
|
|
||||||
`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending message:', error);
|
|
||||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/conversations/[id] - Delete conversation
|
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
||||||
try {
|
|
||||||
const user = getUserFromRequest(request);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
import { Loader2, Send, MessageCircle, Plus, Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
import { Loader2, Send, MessageCircle, Plus, Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
import '@/styles/Messages.css';
|
import '@/styles/Messages.css';
|
||||||
|
|
@ -35,6 +36,7 @@ export default function MessagesPage() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const { refreshUnreadCount } = useNotification();
|
||||||
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||||
|
|
@ -102,6 +104,8 @@ export default function MessagesPage() {
|
||||||
setConversations(prev => prev.map(c =>
|
setConversations(prev => prev.map(c =>
|
||||||
c.id === convId ? { ...c, unread_count: 0 } : c
|
c.id === convId ? { ...c, unread_count: 0 } : c
|
||||||
));
|
));
|
||||||
|
// Refresh global navbar count
|
||||||
|
refreshUnreadCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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.
|
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>
|
||||||
<p className="text-xs sm:text-sm border-t border-[var(--border-color)] pt-4 mt-4">
|
<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>.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { User, MessageCircle } from "lucide-react";
|
||||||
import logo from "../assets/logo-main.png";
|
import logo from "../assets/logo-main.png";
|
||||||
import "../styles/components/Navbar.css";
|
import "../styles/components/Navbar.css";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useNotification } from "../context/NotificationContext";
|
||||||
import NavbarSearch from "./NavbarSearch";
|
import NavbarSearch from "./NavbarSearch";
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ export default function Navbar() {
|
||||||
|
|
||||||
<ul className="nav-links desktop-links">
|
<ul className="nav-links desktop-links">
|
||||||
<li className={pathname === "/" ? "current-menu-item" : ""}>
|
<li className={pathname === "/" ? "current-menu-item" : ""}>
|
||||||
<Link href="/" prefetch={true}>HEM</Link>
|
<a href="/">HEM</a>
|
||||||
</li>
|
</li>
|
||||||
<li className={pathname === "/products" ? "current-menu-item" : ""}>
|
<li className={pathname === "/products" ? "current-menu-item" : ""}>
|
||||||
<Link href="/products" prefetch={true}>PRODUKTER</Link>
|
<Link href="/products" prefetch={true}>PRODUKTER</Link>
|
||||||
|
|
@ -61,7 +62,7 @@ export default function Navbar() {
|
||||||
|
|
||||||
{/* Center: Logo Only */}
|
{/* Center: Logo Only */}
|
||||||
<div className="nav-center">
|
<div className="nav-center">
|
||||||
<Link href="/" className="logo-link">
|
<a href="/" className="logo-link">
|
||||||
<Image
|
<Image
|
||||||
src={logo}
|
src={logo}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
|
|
@ -70,7 +71,7 @@ export default function Navbar() {
|
||||||
className="logo"
|
className="logo"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: profile + theme toggle */}
|
{/* Right side: profile + theme toggle */}
|
||||||
|
|
@ -82,7 +83,7 @@ export default function Navbar() {
|
||||||
|
|
||||||
function NavbarClientExtras({ isAuthenticated, isMounted }: { isAuthenticated: boolean; isMounted: boolean }) {
|
function NavbarClientExtras({ isAuthenticated, isMounted }: { isAuthenticated: boolean; isMounted: boolean }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const { unreadCount } = useNotification();
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
const [showThemeToggle, setShowThemeToggle] = useState(true);
|
const [showThemeToggle, setShowThemeToggle] = useState(true);
|
||||||
|
|
||||||
|
|
@ -124,30 +125,7 @@ function NavbarClientExtras({ isAuthenticated, isMounted }: { isAuthenticated: b
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, [theme]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleTrigger = () => {
|
const handleTrigger = () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ThemeProvider } from "./theme-provider";
|
import { ThemeProvider } from "./theme-provider";
|
||||||
import { AuthProvider } from "../context/AuthContext";
|
import { AuthProvider } from "../context/AuthContext";
|
||||||
|
import { NotificationProvider } from "../context/NotificationContext";
|
||||||
import { ToastProvider } from "./Toast";
|
import { ToastProvider } from "./Toast";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -17,9 +18,11 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<NotificationProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{children}
|
{children}
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</NotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</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;
|
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