nxtgauge-frontend-solid/src/components/dashboard/NotificationsPage.tsx
Tracewebstudio Dev 43f09b78d2 feat: notifications page, auto-apply settings, customer response profiles, job status UI
- NotificationsPage: full paginated notification list with unread filter, mark read, load more
- SettingsPage: AI auto-apply section for job seekers (toggle, preferences, skills/titles/locations, salary range)
- CustomerResponsesPage: enriched professional response cards with avatar, bio, skills, location
- CompanyJobsPage: show rejection reason banner and pending-approval notice on job cards
- NotificationBell: fix "View all" link to /dashboard?nav=notifications (deep-link support)
- dashboard.tsx: ?nav= param reads sidebar page on mount; Notifications added to all role sidebars
- PayU integration: payu.ts lib, payu-return route, wallet buy/invoice pages, marketplace route
- Razorpay removed, replaced by PayU across payments flow
- ProfilePage: photo upload UI with avatar preview for all roles
- PortfolioPage: showcase image upload with file picker and preview
- CompanyApplicationsPage: applicant profile snapshot with avatar, headline, skills, resume download
- profile-fields-config: removed resume_doc from job seeker (resume is now AI-generated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:33:54 +02:00

254 lines
9.5 KiB
TypeScript

import { For, Show, createSignal, onMount } from 'solid-js';
import { Bell } from 'lucide-solid';
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Notification = {
id: string;
title: string;
body: string;
type?: string;
is_read: boolean;
created_at: string;
};
const TYPE_LABELS: Record<string, string> = {
job_application: 'Application',
application_status: 'Application',
lead_request: 'Lead',
lead_approved: 'Lead',
contact_unlocked: 'Contact',
payment: 'Payment',
verification: 'Verification',
system: 'System',
};
function formatTime(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
}
export default function NotificationsPage() {
const [notifications, setNotifications] = createSignal<Notification[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingMore, setLoadingMore] = createSignal(false);
const [page, setPage] = createSignal(1);
const [hasMore, setHasMore] = createSignal(false);
const [unreadCount, setUnreadCount] = createSignal(0);
const [filter, setFilter] = createSignal<'all' | 'unread'>('all');
const [err, setErr] = createSignal('');
const load = async (pg = 1, append = false) => {
if (pg === 1) setLoading(true); else setLoadingMore(true);
setErr('');
try {
const res = await apiFetch(`/api/me/notifications?page=${pg}&limit=20`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const items: Notification[] = Array.isArray(data?.data) ? data.data : [];
setNotifications(prev => append ? [...prev, ...items] : items);
setUnreadCount(data?.unread_count ?? items.filter((n: Notification) => !n.is_read).length);
const total = data?.total ?? data?.meta?.total ?? 0;
setHasMore(pg * 20 < total);
setPage(pg);
} catch {
setErr('Failed to load notifications.');
} finally {
setLoading(false);
setLoadingMore(false);
}
};
onMount(() => load(1));
const markRead = async (id: string) => {
try {
await apiFetch(`/api/me/notifications/${id}/read`, { method: 'PATCH' });
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
} catch { /* ignore */ }
};
const markAllRead = async () => {
try {
await apiFetch('/api/me/notifications/read-all', { method: 'PATCH' });
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
} catch { /* ignore */ }
};
const loadMore = () => load(page() + 1, true);
const visible = () => {
const all = notifications();
return filter() === 'unread' ? all.filter(n => !n.is_read) : all;
};
return (
<div style={{ 'max-width': '720px', display: 'grid', gap: '14px' }}>
{/* Header */}
<div style={{ background: NAVY, 'border-radius': '12px', padding: '16px 20px', display: 'flex', 'align-items': 'center', gap: '12px' }}>
<span style={{ color: ORANGE }}><Bell size={24} /></span>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, 'font-size': '18px', 'font-weight': '800', color: '#fff' }}>Notifications</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: 'rgba(255,255,255,0.65)' }}>
Stay updated on your activity across Nxtgauge.
</p>
</div>
<Show when={unreadCount() > 0}>
<span style={{ background: ORANGE, color: '#fff', 'border-radius': '20px', padding: '2px 10px', 'font-size': '12px', 'font-weight': '700' }}>
{unreadCount()} unread
</span>
</Show>
</div>
{/* Toolbar */}
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px', 'flex-wrap': 'wrap' }}>
<div style={{ display: 'flex', gap: '6px' }}>
{(['all', 'unread'] as const).map(f => (
<button
type="button"
onClick={() => setFilter(f)}
style={{
...BTN_GHOST,
background: filter() === f ? NAVY : '#fff',
color: filter() === f ? '#fff' : '#374151',
border: filter() === f ? `1px solid ${NAVY}` : '1px solid #E5E7EB',
}}
>
{f === 'all' ? 'All' : 'Unread only'}
</button>
))}
</div>
<Show when={unreadCount() > 0}>
<button type="button" onClick={markAllRead} style={{ ...BTN_GHOST, 'margin-left': 'auto' }}>
Mark all read
</button>
</Show>
</div>
{/* List */}
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF', padding: '32px' }}>Loading...</div>
</Show>
<Show when={err()}>
<div style={{ ...CARD, color: '#B91C1C', 'font-size': '13px', padding: '14px 16px' }}>{err()}</div>
</Show>
<Show when={!loading() && visible().length === 0}>
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
<div style={{ 'font-size': '36px', 'margin-bottom': '10px' }}>🔔</div>
<p style={{ margin: 0, 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>
{filter() === 'unread' ? 'No unread notifications' : 'No notifications yet'}
</p>
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Updates on your jobs, applications, and leads appear here.
</p>
</div>
</Show>
<Show when={!loading() && visible().length > 0}>
<div style={{ ...CARD, padding: '0', overflow: 'hidden' }}>
<For each={visible()}>
{(n, i) => (
<div
style={{
padding: '14px 16px',
display: 'flex',
gap: '12px',
'align-items': 'flex-start',
background: n.is_read ? '#fff' : '#FFF7ED',
'border-bottom': i() < visible().length - 1 ? '1px solid #F1F5F9' : 'none',
cursor: n.is_read ? 'default' : 'pointer',
transition: 'background 0.15s',
}}
onClick={() => { if (!n.is_read) markRead(n.id); }}
>
{/* Unread dot */}
<div style={{ 'padding-top': '5px', 'flex-shrink': 0 }}>
<div style={{
width: '8px', height: '8px', 'border-radius': '50%',
background: n.is_read ? 'transparent' : ORANGE,
}} />
</div>
<div style={{ flex: 1, 'min-width': 0 }}>
<div style={{ display: 'flex', 'align-items': 'flex-start', gap: '8px', 'flex-wrap': 'wrap' }}>
<p style={{ margin: 0, 'font-size': '13px', 'font-weight': n.is_read ? '500' : '700', color: '#111827', flex: 1 }}>
{n.title}
</p>
<Show when={n.type && TYPE_LABELS[n.type]}>
<span style={{
'font-size': '10px', 'font-weight': '700', padding: '2px 7px',
'border-radius': '10px', background: '#F3F4F6', color: '#6B7280',
'white-space': 'nowrap', 'flex-shrink': 0,
}}>
{TYPE_LABELS[n.type!]}
</span>
</Show>
</div>
<p style={{ margin: '3px 0 0', 'font-size': '12px', color: '#4B5563', 'line-height': '1.5' }}>
{n.body}
</p>
<p style={{ margin: '5px 0 0', 'font-size': '11px', color: '#9CA3AF' }}>
{formatTime(n.created_at)}
</p>
</div>
<Show when={!n.is_read}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
style={{ ...BTN_GHOST, height: '28px', 'font-size': '11px', 'flex-shrink': 0 }}
>
Mark read
</button>
</Show>
</div>
)}
</For>
</div>
<Show when={hasMore()}>
<div style={{ 'text-align': 'center' }}>
<button
type="button"
onClick={loadMore}
disabled={loadingMore()}
style={{ ...BTN_ORANGE, opacity: loadingMore() ? '0.7' : '1' }}
>
{loadingMore() ? 'Loading...' : 'Load more'}
</button>
</div>
</Show>
</Show>
</div>
);
}