- 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>
254 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|