Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions frontend/app/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";
"use client"; // Next.js: render on the client (uses state and React Query hooks)

import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
// Data hooks (React Query): fetch a page of notifications and mutate read state
import { useGetNotifications } from "@/lib/react-query/hooks/notifications/useGetNotifications";
import { useMarkNotificationRead } from "@/lib/react-query/hooks/notifications/useMarkNotificationRead";
import { useMarkAllRead } from "@/lib/react-query/hooks/notifications/useMarkAllRead";
Expand All @@ -15,8 +16,9 @@ import {
Info,
} from "lucide-react";

// Picks an icon and colour based on the notification type
function NotificationIcon({ type }: { type: Notification["type"] }) {
const cls = "w-4 h-4";
const cls = "w-4 h-4"; // shared size classes for every icon
switch (type) {
case "PAYMENT_SUCCESS":
return <CreditCard className={`${cls} text-emerald-600`} />;
Expand All @@ -26,33 +28,41 @@ function NotificationIcon({ type }: { type: Notification["type"] }) {
return <BookOpen className={`${cls} text-blue-500`} />;
case "BOOKING_CANCELLED":
return <BookOpen className={`${cls} text-orange-500`} />;
// Any other/unknown type falls back to a neutral info icon
default:
return <Info className={`${cls} text-gray-400`} />;
}
}

// Converts an ISO timestamp into a short relative label ("5m ago", "3h ago", "2d ago")
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const diff = Date.now() - new Date(iso).getTime(); // elapsed milliseconds
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
return `${days}d ago`; // no week/month rollup: old items show e.g. "45d ago"
}

export default function NotificationsPage() {
// Current page number (1-based); changing it triggers a new fetch
const [page, setPage] = useState(1);
// Fetch `page` with 20 items per page
const { data, isLoading, isError, refetch } = useGetNotifications(page, 20);
// Mutations for marking a single notification / all notifications as read
const markRead = useMarkNotificationRead();
const markAll = useMarkAllRead();

// Fall back to an empty list while data is undefined
const notifications = data?.data ?? [];
// Pagination info plus the total unread count
const meta = data?.meta;

return (
<DashboardLayout>
{/* Page header: title, unread summary, and "Mark all read" action */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Notifications</h1>
Expand All @@ -62,11 +72,12 @@ export default function NotificationsPage() {
: "All caught up"}
</p>
</div>
{/* Only show the button when there is something to mark as read */}
{(meta?.unreadCount ?? 0) > 0 && (
<button
type="button"
onClick={() => markAll.mutate()}
disabled={markAll.isPending}
disabled={markAll.isPending} // prevent double-submits while the request is in flight
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
Expand All @@ -75,7 +86,9 @@ export default function NotificationsPage() {
)}
</div>

{/* Body has four mutually exclusive states: loading, error, empty, list */}
{isLoading ? (
// Loading: four pulsing placeholder cards (skeleton UI)
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div
Expand All @@ -85,6 +98,7 @@ export default function NotificationsPage() {
))}
</div>
) : isError ? (
// Error: message with a retry button that refetches the query
<div className="text-center py-16 text-gray-500">
<Bell className="w-10 h-10 mx-auto mb-3 text-gray-300" />
<p className="font-medium">Failed to load notifications</p>
Expand All @@ -98,6 +112,7 @@ export default function NotificationsPage() {
</button>
</div>
) : notifications.length === 0 ? (
// Empty state: the user has no notifications at all
<div className="text-center py-16 text-gray-500">
<Bell className="w-10 h-10 mx-auto mb-3 text-gray-300" />
<p className="font-medium">No notifications yet</p>
Expand All @@ -106,26 +121,31 @@ export default function NotificationsPage() {
</p>
</div>
) : (
// Populated list of notification cards
<div className="space-y-2 max-w-2xl">
{notifications.map((n) => (
<div
key={n.id}
// Unread items get a slightly darker border and tinted background
className={`bg-white rounded-xl border p-4 flex items-start gap-3 transition-colors ${
n.isRead
? "border-gray-100"
: "border-gray-200 bg-gray-50/50"
}`}
>
{/* Type icon in a small rounded badge */}
<span className="w-8 h-8 rounded-lg bg-white border border-gray-100 flex items-center justify-center shrink-0 mt-0.5">
<NotificationIcon type={n.type} />
</span>
{/* Text content; min-w-0 lets long messages wrap instead of overflowing */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{n.title}</p>
<p className="text-xs text-gray-500 mt-0.5">{n.message}</p>
<p className="text-xs text-gray-400 mt-1">
{timeAgo(n.createdAt)}
</p>
</div>
{/* Unread indicator dot, doubling as a "mark as read" button */}
{!n.isRead && (
<button
type="button"
Expand All @@ -140,10 +160,12 @@ export default function NotificationsPage() {
))}

{/* Pagination */}
{/* Hidden when everything fits on one page */}
{meta && meta.totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<button
type="button"
// Math.max/min keep the page within [1, totalPages]
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50 transition-colors"
Expand All @@ -167,4 +189,4 @@ export default function NotificationsPage() {
)}
</DashboardLayout>
);
}
}
8 changes: 7 additions & 1 deletion frontend/app/privacy-policy/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
// Shared helper that builds a Next.js Metadata object (SEO tags, Open Graph, etc.)
import { buildMetadata } from "@/lib/seo";

// Next.js reads this exported `metadata` constant to set the page <head> tags
// (title, description, keywords) for every route under this layout.
export const metadata = buildMetadata({
title: "Privacy Policy",
description:
"Learn how Oraculum collects, uses, and protects your personal data. Your privacy matters to us.",
keywords: ["privacy policy", "data protection", "Oraculum", "privacy"],
});

// Pass-through layout: adds no markup of its own. It exists only so the
// segment can export `metadata` (a page marked "use client" can't export it).
export default function PrivacyPolicyLayout({
children,
}: {
children: React.ReactNode;
}) {
// Render the page content unchanged
return children;
}
}
9 changes: 8 additions & 1 deletion frontend/app/profile/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
// Shared helper that builds a Next.js Metadata object (SEO tags, Open Graph, etc.)
import { buildMetadata } from "@/lib/seo";

// Next.js reads this exported `metadata` constant to set the page <head> tags
// for every route under this layout.
export const metadata = buildMetadata({
title: "Profile",
description:
"View and edit your Oraculum profile, update your personal information, and manage your account.",
// Keep this private, user-specific page out of search engine results
noindex: true,
});

// Pass-through layout: adds no markup of its own. It exists only so the
// segment can export `metadata` (a page marked "use client" can't export it).
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
// Render the page content unchanged
return children;
}
}